Skip to main content

simoom_sys/
cpu.rs

1//! CPU count and thread affinity.
2
3use std::io;
4
5/// The CPUs this process may run on, as indices (the affinity mask on Linux and Windows).
6/// Falls back to `0..cpu_count()` (or one CPU) where the mask can't be read.
7pub fn allowed_cpus() -> Vec<usize> {
8    #[cfg(target_os = "linux")]
9    {
10        let mut mask = [0u8; 128]; // 1024 CPUs
11        // SAFETY: the kernel writes at most `mask.len()` bytes; pid 0 is the calling thread.
12        let r = unsafe { crate::os::sys::sched_getaffinity(0, mask.len(), mask.as_mut_ptr()) };
13        if r == 0 {
14            let v: Vec<usize> = (0..mask.len() * 8).filter(|&i| mask[i / 8] & (1 << (i % 8)) != 0).collect();
15            if !v.is_empty() {
16                return v;
17            }
18        }
19    }
20    #[cfg(windows)]
21    {
22        use crate::os::sys;
23        let (mut p, mut s) = (0usize, 0usize);
24        // SAFETY: two output words; the pseudo handle needs no closing.
25        if unsafe { sys::GetProcessAffinityMask(sys::GetCurrentProcess(), &mut p, &mut s) } != 0 && p != 0 {
26            return (0..usize::BITS as usize).filter(|&i| p & (1 << i) != 0).collect();
27        }
28    }
29    #[cfg(any(target_os = "linux", windows))]
30    {
31        vec![0]
32    }
33    #[cfg(not(any(target_os = "linux", windows)))]
34    {
35        (0..cpu_count()).collect()
36    }
37}
38
39/// The number of CPUs this process may use (at least 1): the affinity mask, capped on Linux by
40/// a cgroup v2 CPU quota (`cpu.max`, as containers set it), like
41/// `std::thread::available_parallelism` but without its cgroup v1 code.
42pub fn cpu_count() -> usize {
43    // The affinity mask and the cgroup quota (a few file reads) are read once per process.
44    static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
45    *N.get_or_init(cpu_count_now)
46}
47
48fn cpu_count_now() -> usize {
49    #[cfg(any(target_os = "linux", windows))]
50    {
51        let n = allowed_cpus().len().max(1);
52        #[cfg(target_os = "linux")]
53        if let Some(q) = cgroup_quota() {
54            return n.min(q).max(1);
55        }
56        n
57    }
58    #[cfg(not(any(target_os = "linux", windows)))]
59    {
60        std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
61    }
62}
63
64/// The CPU quota of this process's cgroup v2 (and its parents), rounded up, if one is set.
65#[cfg(target_os = "linux")]
66#[inline(never)]
67fn cgroup_quota() -> Option<usize> {
68    // Bytes, not `str` and `format!`: start-up code, kept small.
69    const ROOT: &str = "/sys/fs/cgroup";
70    let mut buf = [0u8; 4096];
71    let groups = read_small_bytes("/proc/self/cgroup", &mut buf);
72    let path = groups.split(|&b| b == b'\n').find_map(|l| l.strip_prefix(b"0::"))?;
73    let path = std::str::from_utf8(path).ok()?.trim_end_matches('/');
74    let mut dir = String::with_capacity(ROOT.len() + path.len() + 8);
75    dir.push_str(ROOT);
76    dir.push_str(path);
77    let mut best: Option<usize> = None;
78    loop {
79        let len = dir.len();
80        dir.push_str("/cpu.max");
81        let mut b = [0u8; 64];
82        if let Some(q) = parse_cpu_max(read_small_bytes(&dir, &mut b)) {
83            best = Some(best.map_or(q, |b| b.min(q)));
84        }
85        dir.truncate(len);
86        if len <= ROOT.len() {
87            return best;
88        }
89        match dir.rfind('/') {
90            Some(i) => dir.truncate(i),
91            None => return best,
92        }
93    }
94}
95
96/// Whether the network's receive processing concentrates on one CPU (K12): a NIC with a single
97/// receive queue and no RPS runs every packet's softirq on the CPU that takes its interrupt, as on
98/// DigitalOcean's 4-vCPU droplets (about 65% of that CPU at 165k packets/s). Read from the
99/// softirq time per CPU in `/proc/stat`: true when one CPU spent at least half of all the
100/// softirq time since boot, and there was some (2 s). Network receive dominates softirq time on
101/// a busy server; timers and the rest are spread over the CPUs. Linux only; false elsewhere or
102/// when the file can't be read.
103///
104/// When true, it also records that CPU for [`keep_current_thread_off_net_rx`]. (The `NET_RX`
105/// counts of `/proc/softirqs` can't tell it: a NIC's transmit completions are counted there too,
106/// often more often than its receives, but they cost far less: on the K12 droplet 590k on CPU 3
107/// against 342k on CPU 2 in 12 s, with CPU 2 in softirq 65% of the time and CPU 3 10%.)
108#[inline(never)]
109pub fn net_rx_concentrated() -> bool {
110    #[cfg(target_os = "linux")]
111    {
112        // 4 KiB holds the per-CPU lines up to about 40 CPUs (a cut line is not counted)
113        let mut buf = [0u8; 4096];
114        match softirq_concentrated(read_small_bytes("/proc/stat", &mut buf)) {
115            Some(cpu) => {
116                RX_CPU.store(cpu, std::sync::atomic::Ordering::Relaxed);
117                true
118            }
119            None => false,
120        }
121    }
122    #[cfg(not(target_os = "linux"))]
123    {
124        false
125    }
126}
127
128/// The receive-interrupt CPU [`net_rx_concentrated`] found (`usize::MAX`: none).
129#[cfg(target_os = "linux")]
130static RX_CPU: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(usize::MAX);
131
132/// The parsing half of [`net_rx_concentrated`]: the CPU of a `/proc/stat` that spent at least
133/// half of all the softirq time (of each `cpuN` line, N and the seventh number after it: user,
134/// nice, system, idle, iowait, irq, softirq), if the total is at least 2 s (200 ticks).
135#[cfg(target_os = "linux")]
136fn softirq_concentrated(stat: &[u8]) -> Option<usize> {
137    let (mut best, mut most, mut total) = (0, 0u64, 0u64);
138    // the numbers of the current line: number 0 is the CPU's index on a `cpuN` line (the only
139    // lines whose first digit follows a `u`), number 7 its softirq time
140    let (mut n, mut cur, mut cpu, mut digits, mut on, mut prev) = (0, 0u64, 0u64, false, false, 0u8);
141    for &b in stat {
142        if b.is_ascii_digit() {
143            if !digits && n == 0 {
144                on = prev == b'u';
145            }
146            cur = cur.wrapping_mul(10).wrapping_add(u64::from(b - b'0'));
147            digits = true;
148        } else {
149            if digits {
150                if on && n == 0 {
151                    cpu = cur;
152                } else if on && n == 7 {
153                    total = total.wrapping_add(cur);
154                    if cur > most {
155                        (best, most) = (cpu as usize, cur);
156                    }
157                }
158                (n, cur, digits) = (n + 1, 0, false);
159            }
160            if b == b'\n' {
161                n = 0;
162            }
163        }
164        prev = b;
165    }
166    (total >= 200 && most * 2 >= total).then_some(best)
167}
168
169/// Takes the receive-interrupt CPU recorded by [`net_rx_concentrated`] out of the calling
170/// thread's affinity mask (Linux; elsewhere, or when none was recorded, it does nothing).
171/// Nothing changes when the thread may not run there anyway or it is the only CPU left.
172pub fn keep_current_thread_off_net_rx() {
173    #[cfg(target_os = "linux")]
174    keep_current_thread_off(RX_CPU.load(std::sync::atomic::Ordering::Relaxed));
175}
176
177#[cfg(target_os = "linux")]
178fn keep_current_thread_off(cpu: usize) {
179    let mut mask = [0u8; 128];
180    let (byte, bit) = (cpu / 8, 1u8 << (cpu % 8));
181    // SAFETY: the kernel writes at most `mask.len()` bytes; pid 0 is the calling thread.
182    if byte >= mask.len() || unsafe { crate::os::sys::sched_getaffinity(0, mask.len(), mask.as_mut_ptr()) } != 0 || mask[byte] & bit == 0 {
183        return;
184    }
185    mask[byte] &= !bit;
186    if mask.iter().any(|&b| b != 0) {
187        // SAFETY: passes a mask of `mask.len()` bytes; pid 0 is the calling thread.
188        unsafe { crate::os::sys::sched_setaffinity(0, mask.len(), mask.as_ptr()) };
189    }
190}
191
192/// Reads a small file into `buf` (one read: these kernel files are produced whole); empty when
193/// the file can't be read.
194#[cfg(target_os = "linux")]
195#[inline(never)]
196pub(crate) fn read_small_bytes<'b>(path: &str, buf: &'b mut [u8]) -> &'b [u8] {
197    use std::io::Read;
198    let n = std::fs::File::open(path).and_then(|mut f| f.read(buf)).unwrap_or(0);
199    &buf[..n]
200}
201
202/// Parses a cgroup v2 `cpu.max` (`"max 100000"` or `"<quota> <period>"`) into whole CPUs.
203#[cfg(target_os = "linux")]
204fn parse_cpu_max(s: &[u8]) -> Option<usize> {
205    let mut it = s.split(u8::is_ascii_whitespace).filter(|w| !w.is_empty());
206    let quota = parse_u64(it.next()?)?;
207    let period = it.next().map_or(Some(100_000), parse_u64)?;
208    if quota == 0 || period == 0 {
209        return None;
210    }
211    Some(quota.div_ceil(period) as usize)
212}
213
214/// A decimal number of at most 19 digits.
215#[cfg(target_os = "linux")]
216fn parse_u64(d: &[u8]) -> Option<u64> {
217    if d.is_empty() || d.len() > 19 || !d.iter().all(u8::is_ascii_digit) {
218        return None;
219    }
220    Some(d.iter().fold(0, |n, &c| n * 10 + u64::from(c - b'0')))
221}
222
223/// x86-64 instruction-set extensions, bits of [`x86_features`].
224#[cfg(target_arch = "x86_64")]
225pub(crate) mod x86 {
226    pub const SSSE3: u32 = 1 << 0;
227    pub const SSE41: u32 = 1 << 1;
228    pub const SSE42: u32 = 1 << 2;
229    pub const AES: u32 = 1 << 3;
230    pub const PCLMULQDQ: u32 = 1 << 4;
231    pub const SHA: u32 = 1 << 5;
232    pub const AVX2: u32 = 1 << 6;
233    pub const BMI2: u32 = 1 << 7;
234    pub const ADX: u32 = 1 << 8;
235    pub(super) const DONE: u32 = 1 << 31;
236}
237
238/// The x86-64 extensions this CPU and OS support (bits of [`x86`]), read once with `cpuid`
239/// (and `xgetbv` for AVX state). Replaces `std::is_x86_feature_detected!`, whose detection code
240/// covers every extension std knows (about 1.7 KB) when the parsers and crypto need nine.
241#[cfg(target_arch = "x86_64")]
242#[inline]
243pub(crate) fn x86_features() -> u32 {
244    use std::sync::atomic::{AtomicU32, Ordering};
245    static F: AtomicU32 = AtomicU32::new(0);
246    let f = F.load(Ordering::Relaxed);
247    if f != 0 {
248        return f;
249    }
250    let f = detect_x86() | x86::DONE;
251    F.store(f, Ordering::Relaxed);
252    f
253}
254
255#[cfg(target_arch = "x86_64")]
256#[cold]
257#[inline(never)]
258// `__cpuid` is a safe function on newer compilers and `unsafe` on the oldest supported one.
259#[allow(unused_unsafe)]
260fn detect_x86() -> u32 {
261    use core::arch::x86_64::{__cpuid, __cpuid_count};
262    let bit = |r: u32, b: u32, v: u32| if r & (1 << b) != 0 { v } else { 0 };
263    // SAFETY: `cpuid` exists on every x86-64 CPU; leaf 7 is read only when leaf 0 lists it.
264    let (max, l1) = unsafe { (__cpuid(0).eax, __cpuid(1)) };
265    let mut f = bit(l1.ecx, 9, x86::SSSE3) | bit(l1.ecx, 19, x86::SSE41) | bit(l1.ecx, 20, x86::SSE42) | bit(l1.ecx, 25, x86::AES) | bit(l1.ecx, 1, x86::PCLMULQDQ);
266    if max >= 7 {
267        // SAFETY: as above, leaf 7 is supported.
268        let l7 = unsafe { __cpuid_count(7, 0) };
269        f |= bit(l7.ebx, 29, x86::SHA) | bit(l7.ebx, 8, x86::BMI2) | bit(l7.ebx, 19, x86::ADX);
270        // AVX2 also needs AVX and the OS saving the YMM registers (OSXSAVE, then XCR0 bits 1-2).
271        if l7.ebx & (1 << 5) != 0 && l1.ecx & (1 << 27) != 0 && l1.ecx & (1 << 28) != 0 && xcr0() & 6 == 6 {
272            f |= x86::AVX2;
273        }
274    }
275    f
276}
277
278#[cfg(target_arch = "x86_64")]
279fn xcr0() -> u64 {
280    #[target_feature(enable = "xsave")]
281    unsafe fn read() -> u64 {
282        // SAFETY: the caller checked OSXSAVE, so `xgetbv` is enabled.
283        unsafe { core::arch::x86_64::_xgetbv(0) }
284    }
285    // SAFETY: called only after CPUID reported OSXSAVE (see `detect_x86`).
286    unsafe { read() }
287}
288
289/// Pins the calling thread to one CPU. Returns `Unsupported` on macOS, which has no hard
290/// affinity.
291pub fn pin_current_thread(cpu: usize) -> io::Result<()> {
292    #[cfg(target_os = "linux")]
293    {
294        if cpu >= 1024 {
295            return Err(io::Error::new(io::ErrorKind::InvalidInput, "CPU index too large"));
296        }
297        let mut mask = [0u8; 128];
298        mask[cpu / 8] |= 1 << (cpu % 8);
299        // SAFETY: passes a mask of `mask.len()` bytes; pid 0 is the calling thread.
300        if unsafe { crate::os::sys::sched_setaffinity(0, mask.len(), mask.as_ptr()) } != 0 {
301            return Err(io::Error::last_os_error());
302        }
303        Ok(())
304    }
305    #[cfg(windows)]
306    {
307        use crate::os::sys;
308        if cpu >= usize::BITS as usize {
309            return Err(io::Error::new(io::ErrorKind::InvalidInput, "CPU index beyond the first processor group"));
310        }
311        // SAFETY: the pseudo handle of the current thread and a one-bit mask.
312        if unsafe { sys::SetThreadAffinityMask(sys::GetCurrentThread(), 1usize << cpu) } == 0 {
313            return Err(io::Error::last_os_error());
314        }
315        Ok(())
316    }
317    #[cfg(not(any(target_os = "linux", windows)))]
318    {
319        let _ = cpu;
320        Err(io::Error::new(io::ErrorKind::Unsupported, "thread affinity is not supported on this OS"))
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[cfg(target_os = "linux")]
329    #[test]
330    fn cpu_max_parsing() {
331        assert_eq!(parse_cpu_max(b"max 100000
332"), None);
333        assert_eq!(parse_cpu_max(b"200000 100000
334"), Some(2));
335        assert_eq!(parse_cpu_max(b"150000 100000"), Some(2));
336        assert_eq!(parse_cpu_max(b"50000 100000"), Some(1));
337        assert_eq!(parse_cpu_max(b"300000"), Some(3));
338        assert_eq!(parse_cpu_max(b"0 100000"), None);
339        assert_eq!(parse_cpu_max(b""), None);
340        assert!(cpu_count() <= allowed_cpus().len());
341    }
342
343    #[cfg(target_os = "linux")]
344    #[test]
345    fn net_rx_concentration() {
346        // the K12 droplet: CPU 2 takes the private NIC's receive interrupts, CPU 3 its transmit
347        // completions (more NET_RX softirqs there, much less time)
348        let k12 = b"cpu  53911 675 72359 206845 12924 0 29173 141 0 0\ncpu0 15347 395 22098 54484 3504 0 159 36 0 0\ncpu1 15620 280 23352 52153 4305 0 105 38 0 0\ncpu2 9220 0 8644 44155 1482 0 24987 36 0 0\ncpu3 13724 0 18265 56053 3633 0 3922 31 0 0\nintr 123 0 0\n";
349        assert_eq!(softirq_concentrated(k12), Some(2));
350        let spread = b"cpu  1 2 3 4 5 6 4000\ncpu0 1 2 3 4 5 6 1000\ncpu1 1 2 3 4 5 6 900\ncpu2 1 2 3 4 5 6 1100\ncpu3 1 2 3 4 5 6 1000\n";
351        assert_eq!(softirq_concentrated(spread), None);
352        assert_eq!(softirq_concentrated(b"cpu  0 0 0 0 0 0 0\ncpu0 1 2 3 4 5 6 5\ncpu1 1 2 3 4 5 6 150\n"), None, "too little to tell");
353        assert_eq!(softirq_concentrated(b"cpu  9\ncpu0 1 2 3 4 5 6 10\ncpu1 1 2 3 4 5 6 900\ncpu2 1 2 3\n"), Some(1), "a cut line is not counted");
354        assert_eq!(softirq_concentrated(b"cpu  1 2 3 4 5 6 9000\ncpu0 1 2 3 4 5 6 1000\ncpu1 1 2 3 4 5 6 1000\n"), Some(0), "not the total line");
355        assert_eq!(softirq_concentrated(b""), None);
356        let _ = net_rx_concentrated(); // reads the real file without failing
357    }
358
359    #[cfg(target_os = "linux")]
360    #[test]
361    fn a_thread_can_be_kept_off_one_cpu() {
362        let cpus = allowed_cpus();
363        std::thread::spawn(move || {
364            keep_current_thread_off(100_000); // out of range: nothing changes
365            assert_eq!(allowed_cpus(), cpus);
366            keep_current_thread_off(cpus[0]);
367            if cpus.len() > 1 {
368                assert_eq!(allowed_cpus(), cpus[1..], "the first CPU left");
369                for &c in &cpus[2..] {
370                    keep_current_thread_off(c);
371                }
372                assert_eq!(allowed_cpus(), [cpus[1]]);
373                keep_current_thread_off(cpus[1]);
374                assert_eq!(allowed_cpus(), [cpus[1]], "never the last CPU");
375            } else {
376                assert_eq!(allowed_cpus(), cpus, "never the last CPU");
377            }
378        })
379        .join()
380        .unwrap();
381    }
382
383    #[cfg(target_arch = "x86_64")]
384    #[test]
385    fn x86_features_match_std() {
386        let f = x86_features();
387        let checks = [
388            (x86::SSSE3, std::is_x86_feature_detected!("ssse3")),
389            (x86::SSE41, std::is_x86_feature_detected!("sse4.1")),
390            (x86::SSE42, std::is_x86_feature_detected!("sse4.2")),
391            (x86::AES, std::is_x86_feature_detected!("aes")),
392            (x86::PCLMULQDQ, std::is_x86_feature_detected!("pclmulqdq")),
393            (x86::SHA, std::is_x86_feature_detected!("sha")),
394            (x86::AVX2, std::is_x86_feature_detected!("avx2")),
395            (x86::BMI2, std::is_x86_feature_detected!("bmi2")),
396            (x86::ADX, std::is_x86_feature_detected!("adx")),
397        ];
398        for (i, (bit, std)) in checks.into_iter().enumerate() {
399            assert_eq!(f & bit != 0, std, "feature {i}");
400        }
401    }
402
403    #[test]
404    fn count_and_pin() {
405        let cpus = allowed_cpus();
406        assert!(!cpus.is_empty());
407        assert!(cpu_count() >= 1);
408        let last = *cpus.last().unwrap();
409        std::thread::spawn(move || {
410            let _ = last;
411            #[cfg(any(target_os = "linux", windows))]
412            pin_current_thread(last).unwrap();
413            #[cfg(target_os = "linux")]
414            assert_eq!(allowed_cpus(), vec![last]);
415        })
416        .join()
417        .unwrap();
418    }
419}