Skip to main content

wowlab_common/sys/
mod.rs

1// #t(file: rust_forbidden_deps) System-level helper, never compiled for WASM.
2// #t(file: rust_ffi_crate_naming) Private platform adapter owns the narrow sysctl FFI boundary.
3
4//! Platform-specific system information: CPU, memory, load averages.
5
6use wowlab_types::{
7    constants::{BYTES_PER_MB, HUNDRED},
8    sim::FastSet,
9};
10
11const LOAD_AVG_COUNT: usize = 3;
12
13/// CPU tick snapshot used to derive usage across a sampling interval.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct CpuTicks {
16    total: u64,
17    idle: u64,
18}
19
20impl CpuTicks {
21    /// Construct a snapshot from cumulative total and idle tick counters.
22    #[must_use]
23    pub const fn new(total: u64, idle: u64) -> Self {
24        Self { total, idle }
25    }
26
27    /// Return the cumulative total tick counter.
28    #[must_use]
29    pub const fn total(self) -> u64 {
30        self.total
31    }
32
33    /// Return the cumulative idle tick counter.
34    #[must_use]
35    pub const fn idle(self) -> u64 {
36        self.idle
37    }
38}
39
40/// Compute CPU usage percentage from two tick snapshots.
41#[must_use]
42pub fn cpu_usage_from_ticks(before: CpuTicks, after: CpuTicks) -> f64 {
43    let total_delta = after.total.saturating_sub(before.total);
44
45    if total_delta == 0 {
46        return 0.0;
47    }
48
49    let idle_delta = after.idle.saturating_sub(before.idle);
50    let active_delta = total_delta.saturating_sub(idle_delta);
51
52    HUNDRED * wowlab_types::numeric::u64_to_f64(active_delta)
53        / wowlab_types::numeric::u64_to_f64(total_delta)
54}
55
56/// Total number of logical CPU cores.
57#[must_use]
58pub fn logical_cores() -> usize {
59    std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
60}
61
62/// Optimal core count for compute work: P-cores on Apple Silicon, physical on x86.
63#[must_use]
64pub fn optimal_concurrency() -> usize {
65    #[cfg(target_arch = "aarch64")]
66    {
67        aarch64_performance_cores().unwrap_or_else(physical_cores)
68    }
69
70    #[cfg(target_arch = "x86_64")]
71    {
72        physical_cores()
73    }
74
75    #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
76    {
77        logical_cores()
78    }
79}
80
81/// Physical CPU core count (best-effort, falls back to logical).
82#[must_use]
83pub fn physical_cores() -> usize {
84    #[cfg(target_os = "macos")]
85    {
86        let n = sysctl_usize("hw.physicalcpu");
87
88        if n > 0 { n } else { logical_cores() }
89    }
90
91    #[cfg(target_os = "linux")]
92    {
93        let Ok(cpuinfo) = wowlab_fs::file::read_text(wowlab_fs::path::Path::new("/proc/cpuinfo"))
94        else {
95            return logical_cores();
96        };
97        let mut ids = FastSet::default();
98
99        for line in cpuinfo.lines() {
100            if line.starts_with("core id") {
101                if let Some(id) = line
102                    .split(':')
103                    .nth(1)
104                    .and_then(|v| v.trim().parse::<u32>().ok())
105                {
106                    ids.insert(id);
107                }
108            }
109        }
110
111        if ids.is_empty() {
112            logical_cores()
113        } else {
114            ids.len()
115        }
116    }
117
118    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
119    {
120        logical_cores()
121    }
122}
123
124#[cfg(target_arch = "aarch64")]
125fn aarch64_performance_cores() -> Option<usize> {
126    #[cfg(target_os = "macos")]
127    {
128        macos_perflevel_cores(0)
129    }
130
131    #[cfg(target_os = "linux")]
132    {
133        linux_big_cores()
134    }
135
136    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
137    {
138        None
139    }
140}
141
142#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
143fn macos_perflevel_cores(level: u32) -> Option<usize> {
144    use std::{
145        ffi::CString,
146        mem::MaybeUninit,
147        os::raw::{c_int, c_void},
148    };
149
150    extern "C" {
151        fn sysctlbyname(
152            name: *const i8,
153            oldp: *mut c_void,
154            oldlenp: *mut usize,
155            newp: *mut c_void,
156            newlen: usize,
157        ) -> c_int;
158    }
159
160    let name = CString::new(format!("hw.perflevel{level}.logicalcpu")).ok()?;
161    let mut value = MaybeUninit::<i32>::uninit();
162    let mut size = std::mem::size_of::<i32>();
163
164    // SAFETY: valid CString name and correctly sized output buffer.
165    let result = unsafe {
166        sysctlbyname(
167            name.as_ptr(),
168            value.as_mut_ptr().cast(),
169            &mut size,
170            std::ptr::null_mut(),
171            0,
172        )
173    };
174
175    if result == 0 && size == std::mem::size_of::<i32>() {
176        // SAFETY: sysctlbyname returned 0 (success) and wrote exactly size_of::<i32>() bytes.
177        let cores = unsafe { value.assume_init() };
178
179        if cores > 0 {
180            return Some(cores as usize);
181        }
182    }
183
184    None
185}
186
187#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
188const BIG_CORE_CAPACITY_THRESHOLD: u32 = 900;
189
190#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
191fn linux_big_cores() -> Option<usize> {
192    use wowlab_fs::{directory, file, path::Path};
193
194    let cpu_base = Path::new("/sys/devices/system/cpu");
195
196    if directory::inspect(&cpu_base.join("cpu0/cpu_capacity"))
197        .ok()?
198        .is_none()
199    {
200        return None;
201    }
202
203    let mut big_cores = 0;
204    let mut found_any = false;
205
206    for entry in directory::entries(cpu_base).ok()? {
207        let name = entry.path().file_name()?;
208        let name_str = name.to_string_lossy();
209
210        if !name_str.starts_with("cpu") {
211            continue;
212        }
213
214        if name_str
215            .strip_prefix("cpu")
216            .and_then(|s| s.parse::<u32>().ok())
217            .is_none()
218        {
219            continue;
220        }
221
222        let capacity_path = entry.path().join("cpu_capacity");
223
224        if let Ok(content) = file::read_text(&capacity_path) {
225            if let Ok(capacity) = content.trim().parse::<u32>() {
226                found_any = true;
227
228                if capacity >= BIG_CORE_CAPACITY_THRESHOLD {
229                    big_cores += 1;
230                }
231            }
232        }
233    }
234
235    if found_any && big_cores > 0 {
236        Some(big_cores)
237    } else {
238        None
239    }
240}
241
242#[cfg(target_os = "linux")]
243#[path = "linux.rs"]
244mod inner;
245
246#[cfg(target_os = "macos")]
247#[path = "macos.rs"]
248mod inner;
249
250#[cfg(target_os = "windows")]
251#[path = "windows.rs"]
252mod inner;
253
254#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
255#[path = "fallback.rs"]
256mod inner;
257
258pub use inner::{cpu_model, read_cpu_ticks, read_load_average, read_memory_mb, read_os_memory_mb};
259
260/// Performance core count (P-cores on Apple Silicon, physical on x86).
261#[must_use]
262pub fn performance_cores() -> usize {
263    optimal_concurrency()
264}
265
266/// Efficiency core count (E-cores on Apple Silicon, 0 on x86).
267#[must_use]
268pub fn efficiency_cores() -> usize {
269    #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
270    {
271        macos_perflevel_cores(1).unwrap_or(0)
272    }
273
274    #[cfg(all(target_arch = "aarch64", target_os = "linux"))]
275    {
276        let big = optimal_concurrency();
277        let total = physical_cores();
278
279        total.saturating_sub(big)
280    }
281
282    #[cfg(not(target_arch = "aarch64"))]
283    {
284        0
285    }
286}
287
288/// Snapshot of the host machine's hardware and OS environment.
289#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
290pub struct OsProfile {
291    pub cpu_model: String,
292    pub logical_cores: usize,
293    pub physical_cores: usize,
294    pub optimal_cores: usize,
295    pub p_cores: usize,
296    pub e_cores: usize,
297    pub total_memory_mb: u64,
298    pub available_memory_mb: u64,
299    pub os: String,
300    pub arch: String,
301}
302
303/// Capture a snapshot of the current host machine profile.
304#[must_use]
305pub fn os_profile() -> OsProfile {
306    let (total_mem, avail_mem) = read_os_memory_mb();
307
308    OsProfile {
309        cpu_model: cpu_model(),
310        logical_cores: logical_cores(),
311        physical_cores: physical_cores(),
312        optimal_cores: optimal_concurrency(),
313        p_cores: performance_cores(),
314        e_cores: efficiency_cores(),
315        total_memory_mb: wowlab_types::numeric::f64_to_u64_saturating_trunc(total_mem),
316        available_memory_mb: wowlab_types::numeric::f64_to_u64_saturating_trunc(avail_mem),
317        os: std::env::consts::OS.to_string(),
318        arch: std::env::consts::ARCH.to_string(),
319    }
320}
321
322#[cfg(target_os = "macos")]
323fn sysctl_string(name: &str) -> String {
324    use std::ffi::CString;
325
326    let Ok(cname) = CString::new(name) else {
327        return String::new();
328    };
329
330    let mut size: usize = 0;
331    // SAFETY: valid CString name, null output to query size.
332    let ret = unsafe {
333        libc::sysctlbyname(
334            cname.as_ptr(),
335            std::ptr::null_mut(),
336            &mut size,
337            std::ptr::null_mut(),
338            0,
339        )
340    };
341
342    if ret != 0 || size == 0 {
343        return String::new();
344    }
345
346    let mut buf = vec![0u8; size];
347    // SAFETY: correctly sized buffer for sysctlbyname output.
348    let ret = unsafe {
349        libc::sysctlbyname(
350            cname.as_ptr(),
351            buf.as_mut_ptr().cast(),
352            &mut size,
353            std::ptr::null_mut(),
354            0,
355        )
356    };
357
358    if ret != 0 {
359        return String::new();
360    }
361
362    if buf.last() == Some(&0) {
363        buf.pop();
364    }
365
366    String::from_utf8_lossy(&buf).trim().to_string()
367}
368
369#[cfg(target_os = "macos")]
370fn sysctl_usize(name: &str) -> usize {
371    use std::ffi::CString;
372
373    let Ok(cname) = CString::new(name) else {
374        return 0;
375    };
376
377    let mut value: usize = 0;
378    let mut size = std::mem::size_of::<usize>();
379    // SAFETY: valid CString name and correctly sized output buffer.
380    let ret = unsafe {
381        libc::sysctlbyname(
382            cname.as_ptr(),
383            &mut value as *mut _ as *mut libc::c_void,
384            &mut size,
385            std::ptr::null_mut(),
386            0,
387        )
388    };
389
390    if ret == 0 { value } else { 0 }
391}
392
393#[cfg(test)]
394mod tests {
395    use googletest::prelude::*;
396    use rstest::rstest;
397
398    use super::*;
399
400    #[gtest]
401    #[rstest]
402    #[case::zero_total_delta(CpuTicks::new(0, 0), CpuTicks::new(0, 0), 0.0)]
403    #[case::fully_active(CpuTicks::new(0, 0), CpuTicks::new(100, 0), 100.0)]
404    #[case::fully_idle(CpuTicks::new(0, 0), CpuTicks::new(100, 100), 0.0)]
405    #[case::half_active(CpuTicks::new(0, 0), CpuTicks::new(100, 50), 50.0)]
406    #[case::quarter_active_nonzero_base(CpuTicks::new(100, 50), CpuTicks::new(300, 100), 75.0)]
407    #[case::after_before_saturates_total(CpuTicks::new(100, 50), CpuTicks::new(50, 20), 0.0)]
408    #[case::idle_gt_total_saturates_active(CpuTicks::new(0, 0), CpuTicks::new(100, 200), 0.0)]
409    fn cpu_usage_cases(
410        #[case] before: CpuTicks,
411        #[case] after: CpuTicks,
412        #[case] expected: f64,
413    ) -> Result<()> {
414        verify_that!(cpu_usage_from_ticks(before, after), near(expected, 1e-9))
415    }
416
417    #[gtest]
418    fn logical_cores_positive() -> Result<()> {
419        verify_that!(logical_cores(), gt(0))
420    }
421
422    #[gtest]
423    fn optimal_le_logical() -> Result<()> {
424        verify_that!(optimal_concurrency(), le(logical_cores()))
425    }
426
427    #[gtest]
428    fn physical_cores_positive() -> Result<()> {
429        verify_that!(physical_cores(), gt(0))
430    }
431
432    #[gtest]
433    fn load_average_non_negative() -> Result<()> {
434        let avg = read_load_average();
435
436        for v in avg {
437            verify_that!(v, ge(0.0))?;
438        }
439
440        Ok(())
441    }
442
443    #[gtest]
444    fn memory_mb_non_negative() -> Result<()> {
445        verify_that!(read_memory_mb(), ge(0.0))
446    }
447
448    #[gtest]
449    fn os_memory_non_negative() -> Result<()> {
450        let (total, available) = read_os_memory_mb();
451
452        verify_that!(total, ge(0.0))?;
453
454        verify_that!(available, ge(0.0))
455    }
456
457    #[gtest]
458    fn cpu_ticks_some() -> Result<()> {
459        if cfg!(any(
460            target_os = "linux",
461            target_os = "macos",
462            target_os = "windows"
463        )) {
464            verify_that!(read_cpu_ticks(), some(anything()))?;
465        }
466
467        Ok(())
468    }
469}