Skip to main content

gander_core/
affinity.rs

1//! CPU affinity: which cores a process may run on.
2//!
3//! The supported platforms disagree about what affinity *is*, so this module
4//! models the platform honestly instead of pretending they match:
5//!
6//! * Linux has hard affinity. `sched_getaffinity` reads the mask, and the mask is
7//!   enforced by the scheduler, so it can be set without privilege on your own
8//!   processes.
9//! * FreeBSD also has hard affinity, through `cpuset_getaffinity`. The API is a
10//!   `cpuset_t` (1024 bits) rather than a sized byte array, and the *level* and
11//!   *which* arguments select the target: `CPU_LEVEL_WHICH` + `CPU_WHICH_PID`
12//!   means "this process's set", which is what the panel edits.
13//! * macOS has only a *hint*. `thread_policy_set(THREAD_AFFINITY_POLICY)` tags a
14//!   thread with an affinity group, and the scheduler uses it as a preference.
15//!   There is no per-process mask, and reading it back is not supported. Leaving
16//!   the mask unset on macOS is therefore correct, not a missing feature.
17//!
18//! The mask is a `Vec<u64>`, one bit per core, which is how Linux stores it. The
19//! platform glue lives behind `#[cfg]` at the bottom so the rendering and the
20//! toggle logic can be tested anywhere.
21
22/// A set of CPUs, one bit per core.
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct CpuMask {
25    words: Vec<u64>,
26}
27
28impl CpuMask {
29    /// An empty mask, sized for `cores` cores.
30    pub fn empty(cores: usize) -> Self {
31        Self {
32            words: vec![0; cores.div_ceil(64).max(1)],
33        }
34    }
35
36    /// Every core set, sized for `cores` cores.
37    pub fn all(cores: usize) -> Self {
38        let mut mask = Self::empty(cores);
39        for core in 0..cores {
40            mask.set(core, true);
41        }
42        mask
43    }
44
45    pub fn get(&self, core: usize) -> bool {
46        let (word, bit) = (core / 64, core % 64);
47        self.words.get(word).is_some_and(|w| w & (1 << bit) != 0)
48    }
49
50    pub fn set(&mut self, core: usize, on: bool) {
51        let (word, bit) = (core / 64, core % 64);
52        if word >= self.words.len() {
53            self.words.resize(word + 1, 0);
54        }
55        if on {
56            self.words[word] |= 1 << bit;
57        } else {
58            self.words[word] &= !(1 << bit);
59        }
60    }
61
62    pub fn toggle(&mut self, core: usize) {
63        let on = self.get(core);
64        self.set(core, !on);
65    }
66
67    /// Flatten to bytes, the form `sched_setaffinity` wants.
68    pub fn to_bytes(&self) -> Vec<u8> {
69        self.words
70            .iter()
71            .flat_map(|w| w.to_ne_bytes())
72            .collect()
73    }
74
75    /// Build from raw bytes, as returned by `sched_getaffinity`.
76    pub fn from_bytes(bytes: &[u8], cores: usize) -> Self {
77        let mut mask = Self::empty(cores);
78        for core in 0..cores {
79            let byte = core / 8;
80            if byte < bytes.len() && bytes[byte] & (1 << (core % 8)) != 0 {
81                mask.set(core, true);
82            }
83        }
84        mask
85    }
86
87    pub fn count(&self) -> usize {
88        self.words.iter().map(|w| w.count_ones() as usize).sum()
89    }
90
91    pub fn is_empty(&self) -> bool {
92        self.count() == 0
93    }
94}
95
96/// Whether this build can change affinity at all.
97pub fn supported() -> bool {
98    cfg!(any(target_os = "linux", target_os = "freebsd"))
99}
100
101/// Read the affinity mask of `pid`.
102///
103/// Returns `Ok(None)` where the platform has no readable mask: that is a fact
104/// about the OS, not an error.
105pub fn read(pid: u32, cores: usize) -> Result<Option<CpuMask>, String> {
106    imp::read(pid, cores)
107}
108
109/// Write the affinity mask of `pid`.
110pub fn write(pid: u32, mask: &CpuMask) -> Result<(), String> {
111    if mask.is_empty() {
112        // A zero mask is rejected by the kernel, and it would mean "no core at
113        // all", which is not a state a process can be in.
114        return Err("refusing to set an empty CPU mask".to_string());
115    }
116    imp::write(pid, mask)
117}
118
119#[cfg(target_os = "linux")]
120mod imp {
121    use super::CpuMask;
122    use std::io;
123
124    pub fn read(pid: u32, cores: usize) -> Result<Option<CpuMask>, String> {
125        let mut bytes = vec![0u8; cores.div_ceil(64).max(1) * 8];
126        // SAFETY: the buffer is `bytes.len()` bytes and the length matches.
127        let rc = unsafe {
128            libc::sched_getaffinity(
129                pid as libc::pid_t,
130                bytes.len(),
131                bytes.as_mut_ptr().cast(),
132            )
133        };
134        if rc != 0 {
135            return Err(io::Error::last_os_error().to_string());
136        }
137        Ok(Some(CpuMask::from_bytes(&bytes, cores)))
138    }
139
140    pub fn write(pid: u32, mask: &CpuMask) -> Result<(), String> {
141        let bytes = mask.to_bytes();
142        // SAFETY: the buffer is `bytes.len()` bytes and the length matches.
143        let rc = unsafe {
144            libc::sched_setaffinity(
145                pid as libc::pid_t,
146                bytes.len(),
147                bytes.as_ptr().cast(),
148            )
149        };
150        if rc == 0 {
151            Ok(())
152        } else {
153            Err(io::Error::last_os_error().to_string())
154        }
155    }
156}
157
158/// FreeBSD: `cpuset_getaffinity`/`cpuset_setaffinity`.
159///
160/// The kernel's `cpuset_t` is opaque in `libc` (its bit array is private), and
161/// the syscall takes the *size* of the set, so this wraps the struct rather than
162/// reimplementing the bit layout. Going through `CpuMask` keeps one model for the
163/// UI: bits in, bits out.
164#[cfg(target_os = "freebsd")]
165mod imp {
166    use super::CpuMask;
167
168    pub fn read(pid: u32, cores: usize) -> Result<Option<CpuMask>, String> {
169        let mut set = std::mem::MaybeUninit::<libc::cpuset_t>::zeroed();
170        // SAFETY: `set` is one `cpuset_t` and its size is passed, as the syscall
171        // requires.
172        let rc = unsafe {
173            libc::cpuset_getaffinity(
174                libc::CPU_LEVEL_WHICH,
175                libc::CPU_WHICH_PID,
176                pid as libc::id_t,
177                std::mem::size_of::<libc::cpuset_t>(),
178                set.as_mut_ptr(),
179            )
180        };
181        if rc != 0 {
182            return Err(std::io::Error::last_os_error().to_string());
183        }
184        // SAFETY: a zero return means the whole set was filled.
185        let set = unsafe { set.assume_init() };
186        // `cpuset_t`'s fields are private, so it is read as bytes: the bit for
187        // core N is byte N/8, bit N%8, which is the same layout Linux uses for
188        // `sched_getaffinity` and therefore the layout `CpuMask::from_bytes`
189        // already understands.
190        //
191        // SAFETY: `cpuset_t` is a plain array of integers with no padding, so
192        // every byte of it is initialised.
193        let bytes = unsafe {
194            std::slice::from_raw_parts(
195                (&set as *const libc::cpuset_t).cast::<u8>(),
196                std::mem::size_of::<libc::cpuset_t>(),
197            )
198        };
199        Ok(Some(CpuMask::from_bytes(bytes, cores)))
200    }
201
202    pub fn write(pid: u32, mask: &CpuMask) -> Result<(), String> {
203        let mut set = std::mem::MaybeUninit::<libc::cpuset_t>::zeroed();
204        // SAFETY: `set` is one `cpuset_t` and is fully initialised before use.
205        let ptr = set.as_mut_ptr();
206        unsafe {
207            std::ptr::write_bytes(ptr.cast::<u8>(), 0, std::mem::size_of::<libc::cpuset_t>());
208            let bytes =
209                std::slice::from_raw_parts_mut(ptr.cast::<u8>(), std::mem::size_of::<libc::cpuset_t>());
210            for (i, b) in mask.to_bytes().into_iter().enumerate() {
211                if let Some(slot) = bytes.get_mut(i) {
212                    *slot = b;
213                }
214            }
215        }
216        // SAFETY: `set` was fully initialised above and its size is passed.
217        let rc = unsafe {
218            libc::cpuset_setaffinity(
219                libc::CPU_LEVEL_WHICH,
220                libc::CPU_WHICH_PID,
221                pid as libc::id_t,
222                std::mem::size_of::<libc::cpuset_t>(),
223                set.as_ptr(),
224            )
225        };
226        if rc == 0 {
227            Ok(())
228        } else {
229            Err(std::io::Error::last_os_error().to_string())
230        }
231    }
232}
233
234/// macOS (and everything else): no per-process mask.
235#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
236mod imp {
237    use super::CpuMask;
238
239    pub fn read(_pid: u32, _cores: usize) -> Result<Option<CpuMask>, String> {
240        // macOS has no per-process mask. Returning `None` rather than an error
241        // lets the panel say "not supported here" instead of looking broken.
242        Ok(None)
243    }
244
245    pub fn write(_pid: u32, _mask: &CpuMask) -> Result<(), String> {
246        Err(
247            "this platform has no per-process CPU affinity mask; \
248             macOS only offers a scheduler hint"
249                .to_string(),
250        )
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn bits_round_trip() {
260        let mut m = CpuMask::empty(16);
261        assert_eq!(m.count(), 0);
262        m.set(0, true);
263        m.set(15, true);
264        assert!(m.get(0) && m.get(15));
265        assert!(!m.get(7));
266        assert_eq!(m.count(), 2);
267
268        m.toggle(15);
269        assert!(!m.get(15));
270        m.toggle(15);
271        assert!(m.get(15));
272    }
273
274    #[test]
275    fn all_covers_every_core() {
276        for cores in [1usize, 8, 64, 65, 128] {
277            let m = CpuMask::all(cores);
278            assert_eq!(m.count(), cores, "cores={cores}");
279            assert!(m.get(cores - 1));
280        }
281    }
282
283    #[test]
284    fn a_mask_beyond_one_word_works() {
285        let mut m = CpuMask::all(100);
286        assert_eq!(m.count(), 100);
287        m.set(99, false);
288        assert_eq!(m.count(), 99);
289        assert!(m.get(64));
290    }
291
292    #[test]
293    fn bytes_round_trip() {
294        let m = CpuMask::all(40);
295        let back = CpuMask::from_bytes(&m.to_bytes(), 40);
296        assert_eq!(m, back);
297    }
298
299    #[test]
300    fn a_short_byte_buffer_is_not_trusted() {
301        // The kernel may return fewer bytes than we asked for on an old machine.
302        let back = CpuMask::from_bytes(&[0b0000_0101], 64);
303        assert!(back.get(0));
304        assert!(back.get(2));
305        assert!(!back.get(8), "bits past the buffer must be clear, not garbage");
306    }
307
308    #[test]
309    fn an_empty_mask_is_rejected() {
310        let err = write(std::process::id(), &CpuMask::empty(4));
311        assert!(err.is_err(), "an empty mask must never reach the kernel");
312    }
313
314    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
315    #[test]
316    fn reading_our_own_affinity_works_and_is_never_empty() {
317        let cores = std::thread::available_parallelism()
318            .map(|n| n.get())
319            .unwrap_or(1);
320        let mask = read(std::process::id(), cores).expect("no error").expect("a mask");
321        assert!(!mask.is_empty(), "a running process has at least one CPU");
322        assert!(mask.count() <= cores);
323    }
324
325    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
326    #[test]
327    fn setting_our_own_affinity_round_trips() {
328        let cores = std::thread::available_parallelism()
329            .map(|n| n.get())
330            .unwrap_or(1);
331        let original = read(std::process::id(), cores).unwrap().unwrap();
332
333        // Pin to CPU 0 and read it back.
334        let mut wanted = CpuMask::empty(cores);
335        wanted.set(0, true);
336        if write(std::process::id(), &wanted).is_ok() {
337            let got = read(std::process::id(), cores).unwrap().unwrap();
338            assert!(got.get(0), "the mask should now contain CPU 0");
339            assert_eq!(got.count(), 1);
340        }
341
342        // Always restore, so the test does not slow the rest of the suite down.
343        let _ = write(std::process::id(), &original);
344    }
345
346    #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
347    #[test]
348    fn platforms_without_a_mask_say_so() {
349        assert!(!supported());
350        assert_eq!(read(std::process::id(), 8), Ok(None));
351        let err = write(std::process::id(), &CpuMask::all(8)).unwrap_err();
352        assert!(err.contains("no per-process CPU affinity"), "got {err:?}");
353    }
354}