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                && 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        if ids.is_empty() {
111            logical_cores()
112        } else {
113            ids.len()
114        }
115    }
116
117    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
118    {
119        logical_cores()
120    }
121}
122
123#[cfg(target_arch = "aarch64")]
124fn aarch64_performance_cores() -> Option<usize> {
125    #[cfg(target_os = "macos")]
126    {
127        macos_perflevel_cores(0)
128    }
129
130    #[cfg(target_os = "linux")]
131    {
132        linux_big_cores()
133    }
134
135    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
136    {
137        None
138    }
139}
140
141#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
142fn macos_perflevel_cores(level: u32) -> Option<usize> {
143    use std::{
144        ffi::CString,
145        mem::MaybeUninit,
146        os::raw::{c_int, c_void},
147    };
148
149    extern "C" {
150        fn sysctlbyname(
151            name: *const i8,
152            oldp: *mut c_void,
153            oldlenp: *mut usize,
154            newp: *mut c_void,
155            newlen: usize,
156        ) -> c_int;
157    }
158
159    let name = CString::new(format!("hw.perflevel{level}.logicalcpu")).ok()?;
160    let mut value = MaybeUninit::<i32>::uninit();
161    let mut size = std::mem::size_of::<i32>();
162
163    // SAFETY: valid CString name and correctly sized output buffer.
164    let result = unsafe {
165        sysctlbyname(
166            name.as_ptr(),
167            value.as_mut_ptr().cast(),
168            &mut size,
169            std::ptr::null_mut(),
170            0,
171        )
172    };
173
174    if result == 0 && size == std::mem::size_of::<i32>() {
175        // SAFETY: sysctlbyname returned 0 (success) and wrote exactly size_of::<i32>() bytes.
176        let cores = unsafe { value.assume_init() };
177
178        if cores > 0 {
179            return Some(cores as usize);
180        }
181    }
182
183    None
184}
185
186#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
187const BIG_CORE_CAPACITY_THRESHOLD: u32 = 900;
188
189#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
190fn linux_big_cores() -> Option<usize> {
191    use wowlab_fs::{directory, file, path::Path};
192
193    let cpu_base = Path::new("/sys/devices/system/cpu");
194
195    if directory::inspect(&cpu_base.join("cpu0/cpu_capacity"))
196        .ok()?
197        .is_none()
198    {
199        return None;
200    }
201
202    let mut big_cores = 0;
203    let mut found_any = false;
204
205    for entry in directory::entries(cpu_base).ok()? {
206        let name = entry.path().file_name()?;
207        let name_str = name.to_string_lossy();
208
209        if !name_str.starts_with("cpu") {
210            continue;
211        }
212
213        if name_str
214            .strip_prefix("cpu")
215            .and_then(|s| s.parse::<u32>().ok())
216            .is_none()
217        {
218            continue;
219        }
220
221        let capacity_path = entry.path().join("cpu_capacity");
222
223        if let Ok(content) = file::read_text(&capacity_path) {
224            if let Ok(capacity) = content.trim().parse::<u32>() {
225                found_any = true;
226
227                if capacity >= BIG_CORE_CAPACITY_THRESHOLD {
228                    big_cores += 1;
229                }
230            }
231        }
232    }
233
234    if found_any && big_cores > 0 {
235        Some(big_cores)
236    } else {
237        None
238    }
239}
240
241#[cfg(target_os = "linux")]
242#[path = "linux.rs"]
243mod inner;
244
245#[cfg(target_os = "macos")]
246#[path = "macos.rs"]
247mod inner;
248
249#[cfg(target_os = "windows")]
250#[path = "windows.rs"]
251mod inner;
252
253#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
254#[path = "fallback.rs"]
255mod inner;
256
257pub use inner::{cpu_model, read_cpu_ticks, read_load_average, read_memory_mb, read_os_memory_mb};
258
259/// Performance core count (P-cores on Apple Silicon, physical on x86).
260#[must_use]
261pub fn performance_cores() -> usize {
262    optimal_concurrency()
263}
264
265/// Efficiency core count (E-cores on Apple Silicon, 0 on x86).
266#[must_use]
267pub fn efficiency_cores() -> usize {
268    #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
269    {
270        macos_perflevel_cores(1).unwrap_or(0)
271    }
272
273    #[cfg(all(target_arch = "aarch64", target_os = "linux"))]
274    {
275        let big = optimal_concurrency();
276        let total = physical_cores();
277
278        total.saturating_sub(big)
279    }
280
281    #[cfg(not(target_arch = "aarch64"))]
282    {
283        0
284    }
285}
286
287/// Snapshot of the host machine's hardware and OS environment.
288#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
289pub struct OsProfile {
290    pub cpu_model: String,
291    pub logical_cores: usize,
292    pub physical_cores: usize,
293    pub optimal_cores: usize,
294    pub p_cores: usize,
295    pub e_cores: usize,
296    pub total_memory_mb: u64,
297    pub available_memory_mb: u64,
298    pub os: String,
299    pub arch: String,
300}
301
302/// Capture a snapshot of the current host machine profile.
303#[must_use]
304pub fn os_profile() -> OsProfile {
305    let (total_mem, avail_mem) = read_os_memory_mb();
306
307    OsProfile {
308        cpu_model: cpu_model(),
309        logical_cores: logical_cores(),
310        physical_cores: physical_cores(),
311        optimal_cores: optimal_concurrency(),
312        p_cores: performance_cores(),
313        e_cores: efficiency_cores(),
314        total_memory_mb: wowlab_types::numeric::f64_to_u64_saturating_trunc(total_mem),
315        available_memory_mb: wowlab_types::numeric::f64_to_u64_saturating_trunc(avail_mem),
316        os: std::env::consts::OS.to_string(),
317        arch: std::env::consts::ARCH.to_string(),
318    }
319}
320
321#[cfg(target_os = "macos")]
322fn sysctl_string(name: &str) -> String {
323    use std::ffi::CString;
324
325    let Ok(cname) = CString::new(name) else {
326        return String::new();
327    };
328
329    let mut size: usize = 0;
330    // SAFETY: valid CString name, null output to query size.
331    let ret = unsafe {
332        libc::sysctlbyname(
333            cname.as_ptr(),
334            std::ptr::null_mut(),
335            &mut size,
336            std::ptr::null_mut(),
337            0,
338        )
339    };
340
341    if ret != 0 || size == 0 {
342        return String::new();
343    }
344
345    let mut buf = vec![0u8; size];
346    // SAFETY: correctly sized buffer for sysctlbyname output.
347    let ret = unsafe {
348        libc::sysctlbyname(
349            cname.as_ptr(),
350            buf.as_mut_ptr().cast(),
351            &mut size,
352            std::ptr::null_mut(),
353            0,
354        )
355    };
356
357    if ret != 0 {
358        return String::new();
359    }
360
361    if buf.last() == Some(&0) {
362        buf.pop();
363    }
364
365    String::from_utf8_lossy(&buf).trim().to_string()
366}
367
368#[cfg(target_os = "macos")]
369fn sysctl_usize(name: &str) -> usize {
370    use std::ffi::CString;
371
372    let Ok(cname) = CString::new(name) else {
373        return 0;
374    };
375
376    let mut value: usize = 0;
377    let mut size = std::mem::size_of::<usize>();
378    // SAFETY: valid CString name and correctly sized output buffer.
379    let ret = unsafe {
380        libc::sysctlbyname(
381            cname.as_ptr(),
382            &mut value as *mut _ as *mut libc::c_void,
383            &mut size,
384            std::ptr::null_mut(),
385            0,
386        )
387    };
388
389    if ret == 0 { value } else { 0 }
390}
391
392#[cfg(test)]
393mod tests {
394    use googletest::prelude::*;
395    use rstest::rstest;
396
397    use super::*;
398
399    #[gtest]
400    #[rstest]
401    #[case::zero_total_delta(CpuTicks::new(0, 0), CpuTicks::new(0, 0), 0.0)]
402    #[case::fully_active(CpuTicks::new(0, 0), CpuTicks::new(100, 0), 100.0)]
403    #[case::fully_idle(CpuTicks::new(0, 0), CpuTicks::new(100, 100), 0.0)]
404    #[case::half_active(CpuTicks::new(0, 0), CpuTicks::new(100, 50), 50.0)]
405    #[case::quarter_active_nonzero_base(CpuTicks::new(100, 50), CpuTicks::new(300, 100), 75.0)]
406    #[case::after_before_saturates_total(CpuTicks::new(100, 50), CpuTicks::new(50, 20), 0.0)]
407    #[case::idle_gt_total_saturates_active(CpuTicks::new(0, 0), CpuTicks::new(100, 200), 0.0)]
408    fn cpu_usage_cases(
409        #[case] before: CpuTicks,
410        #[case] after: CpuTicks,
411        #[case] expected: f64,
412    ) -> Result<()> {
413        verify_that!(cpu_usage_from_ticks(before, after), near(expected, 1e-9))
414    }
415
416    #[gtest]
417    fn logical_cores_positive() -> Result<()> {
418        verify_that!(logical_cores(), gt(0))
419    }
420
421    #[gtest]
422    fn optimal_le_logical() -> Result<()> {
423        verify_that!(optimal_concurrency(), le(logical_cores()))
424    }
425
426    #[gtest]
427    fn physical_cores_positive() -> Result<()> {
428        verify_that!(physical_cores(), gt(0))
429    }
430
431    #[gtest]
432    fn load_average_non_negative() -> Result<()> {
433        let avg = read_load_average();
434
435        for v in avg {
436            verify_that!(v, ge(0.0))?;
437        }
438
439        Ok(())
440    }
441
442    #[gtest]
443    fn memory_mb_non_negative() -> Result<()> {
444        verify_that!(read_memory_mb(), ge(0.0))
445    }
446
447    #[gtest]
448    fn os_memory_non_negative() -> Result<()> {
449        let (total, available) = read_os_memory_mb();
450
451        verify_that!(total, ge(0.0))?;
452
453        verify_that!(available, ge(0.0))
454    }
455
456    #[gtest]
457    fn cpu_ticks_some() -> Result<()> {
458        if cfg!(any(
459            target_os = "linux",
460            target_os = "macos",
461            target_os = "windows"
462        )) {
463            verify_that!(read_cpu_ticks(), some(anything()))?;
464        }
465
466        Ok(())
467    }
468}