Skip to main content

tenferro_cpu/
affinity.rs

1use std::num::NonZeroUsize;
2
3use thiserror::Error;
4
5use crate::{CpuId, CpuSet};
6
7/// Typed failures from the operating-system CPU-affinity boundary.
8///
9/// These errors stay CPU-local until a context-construction failure is
10/// reported to the tensor API, where the complete value is retained as the
11/// source of [`crate::CpuContextError`].
12///
13/// # Examples
14///
15/// ```
16/// use tenferro_cpu::CpuAffinityError;
17///
18/// let error = CpuAffinityError::UnsupportedPlatform;
19/// assert!(error.to_string().contains("unsupported"));
20/// ```
21#[derive(Debug, Error)]
22pub enum CpuAffinityError {
23    /// Constructing the one-CPU mask failed because the CPU set was invalid.
24    #[error("CPU affinity set is invalid: {0}")]
25    CpuSet(#[from] crate::CpuSetError),
26    /// The operating system rejected the requested affinity mask.
27    #[error("setting thread affinity failed: {source}")]
28    Set {
29        #[source]
30        source: std::io::Error,
31    },
32    /// Querying the current worker affinity failed.
33    #[error("querying worker affinity failed: {source}")]
34    Query {
35        #[source]
36        source: std::io::Error,
37    },
38    /// The platform has no supported thread-affinity implementation.
39    #[error("setting thread affinity is unsupported on this platform")]
40    UnsupportedPlatform,
41    /// The operating system did not expose a process affinity mask.
42    #[error("failed to verify worker affinity")]
43    VerificationUnavailable,
44    /// The returned affinity did not contain exactly the requested worker.
45    #[error("verification returned affinity {observed:?}")]
46    Verification { observed: Vec<CpuId> },
47    /// The requested CPU would overflow the affinity-mask size calculation.
48    #[error("affinity mask size overflow")]
49    MaskSizeOverflow,
50    /// The requested CPU exceeds the supported mask allocation limit.
51    #[error("CPU {cpu} exceeds supported affinity mask size of {max_bytes} bytes")]
52    MaskTooLarge { cpu: CpuId, max_bytes: usize },
53    /// Allocating the operating-system affinity mask failed.
54    #[error("failed to allocate affinity mask: {source}")]
55    MaskAllocation {
56        #[source]
57        source: std::collections::TryReserveError,
58    },
59    /// The affinity mask had no CPU entries.
60    #[error("cannot set an empty affinity mask")]
61    EmptyMask,
62}
63
64pub(crate) trait ThreadAffinity: Clone + Send + Sync + 'static {
65    fn pin_current(&self, cpu: CpuId) -> Result<CpuSet, CpuAffinityError>;
66}
67
68#[derive(Clone, Copy, Debug)]
69pub(crate) struct SystemThreadAffinity;
70
71impl ThreadAffinity for SystemThreadAffinity {
72    fn pin_current(&self, cpu: CpuId) -> Result<CpuSet, CpuAffinityError> {
73        set_current_thread_affinity(&CpuSet::new([cpu])?)?;
74        process_cpu_affinity().ok_or(CpuAffinityError::VerificationUnavailable)
75    }
76}
77
78#[cfg(all(test, target_os = "linux"))]
79pub(crate) fn current_cpu() -> Result<CpuId, CpuAffinityError> {
80    unsafe extern "C" {
81        fn sched_getcpu() -> i32;
82    }
83    // SAFETY: `sched_getcpu` takes no arguments and returns the calling
84    // thread's current logical CPU or a negative error sentinel.
85    let cpu = unsafe { sched_getcpu() };
86    usize::try_from(cpu)
87        .map(CpuId::new)
88        .map_err(|_| CpuAffinityError::Query {
89            source: std::io::Error::last_os_error(),
90        })
91}
92
93fn set_current_thread_affinity(cpus: &CpuSet) -> Result<(), CpuAffinityError> {
94    #[cfg(any(target_os = "linux", target_os = "android"))]
95    {
96        unsafe extern "C" {
97            fn sched_setaffinity(
98                pid: i32,
99                cpusetsize: usize,
100                mask: *const core::ffi::c_void,
101            ) -> i32;
102        }
103
104        let mask = build_affinity_mask(cpus)?;
105        // SAFETY: `mask` remains allocated for the call, `cpusetsize` exactly
106        // matches its byte length, and pid 0 selects the calling thread.
107        let rc =
108            unsafe { sched_setaffinity(0, mask.len(), mask.as_ptr().cast::<core::ffi::c_void>()) };
109        (rc == 0)
110            .then_some(())
111            .ok_or_else(|| CpuAffinityError::Set {
112                source: std::io::Error::last_os_error(),
113            })
114    }
115    #[cfg(not(any(target_os = "linux", target_os = "android")))]
116    {
117        let _ = cpus;
118        Err(CpuAffinityError::UnsupportedPlatform)
119    }
120}
121
122#[cfg(any(target_os = "linux", target_os = "android", test))]
123fn build_affinity_mask(cpus: &CpuSet) -> Result<Vec<u8>, CpuAffinityError> {
124    const MIN_MASK_BYTES: usize = 128;
125    const MAX_MASK_BYTES: usize = 1 << 20;
126
127    let highest_cpu = cpus
128        .as_slice()
129        .last()
130        .copied()
131        .ok_or(CpuAffinityError::EmptyMask)?;
132    let required_bytes = highest_cpu
133        .as_usize()
134        .checked_div(u8::BITS as usize)
135        .and_then(|index| index.checked_add(1))
136        .ok_or(CpuAffinityError::MaskSizeOverflow)?
137        .max(MIN_MASK_BYTES);
138    if required_bytes > MAX_MASK_BYTES {
139        return Err(CpuAffinityError::MaskTooLarge {
140            cpu: highest_cpu,
141            max_bytes: MAX_MASK_BYTES,
142        });
143    }
144
145    let mut mask = Vec::new();
146    mask.try_reserve_exact(required_bytes)
147        .map_err(|source| CpuAffinityError::MaskAllocation { source })?;
148    mask.resize(required_bytes, 0u8);
149    for cpu in cpus.as_slice() {
150        let byte = cpu.as_usize() / u8::BITS as usize;
151        let bit = cpu.as_usize() % u8::BITS as usize;
152        mask[byte] |= 1 << bit;
153    }
154    Ok(mask)
155}
156
157/// Return a best-effort CPU count available to the current process.
158///
159/// This first tries an OS-standard process-affinity query when supported, then
160/// falls back to `std::thread::available_parallelism()`, and finally to `1`.
161///
162/// # Examples
163///
164/// ```
165/// let available = tenferro_cpu::available_parallelism();
166/// assert!(available >= 1);
167/// ```
168pub fn available_parallelism() -> usize {
169    process_cpu_affinity_count()
170        .or_else(standard_available_parallelism)
171        .unwrap_or(1)
172}
173
174/// Return the current process affinity mask size when the platform exposes a
175/// standard affinity API.
176///
177/// Platforms without an affinity query return `None`.
178///
179/// # Examples
180///
181/// ```
182/// let count = tenferro_cpu::process_cpu_affinity_count();
183/// if let Some(count) = count {
184///     assert!(count >= 1);
185/// }
186/// ```
187pub fn process_cpu_affinity_count() -> Option<usize> {
188    platform_process_cpu_affinity_count()
189}
190
191/// Return the process affinity mask as logical CPU identifiers when supported.
192///
193/// The returned set preserves sparse operating-system CPU IDs. Platforms where
194/// the standard affinity API exposes only a count return `None`.
195///
196/// # Examples
197///
198/// ```
199/// if let Some(cpus) = tenferro_cpu::process_cpu_affinity() {
200///     assert!(!cpus.is_empty());
201/// }
202/// ```
203pub fn process_cpu_affinity() -> Option<CpuSet> {
204    platform_process_cpu_affinity()
205}
206
207pub(crate) fn standard_available_parallelism() -> Option<usize> {
208    std::thread::available_parallelism()
209        .ok()
210        .map(NonZeroUsize::get)
211}
212
213#[cfg(test)]
214fn count_affinity_mask_bits(mask: &[u8]) -> Option<usize> {
215    cpu_set_from_affinity_mask(mask).map(|cpus| cpus.len())
216}
217
218#[cfg(any(target_os = "linux", target_os = "android", test))]
219fn cpu_set_from_affinity_mask(mask: &[u8]) -> Option<CpuSet> {
220    let cpus = mask.iter().enumerate().flat_map(|(byte_index, byte)| {
221        (0..u8::BITS as usize)
222            .filter(move |bit| byte & (1 << bit) != 0)
223            .map(move |bit| CpuId::new(byte_index * u8::BITS as usize + bit))
224    });
225    CpuSet::new(cpus).ok()
226}
227
228#[cfg(any(target_os = "linux", target_os = "android"))]
229const LINUX_EINVAL: i32 = 22;
230
231#[cfg(any(target_os = "linux", target_os = "android"))]
232fn linux_next_affinity_mask_bytes(mask_bytes: usize, errno: Option<i32>) -> Option<usize> {
233    (errno == Some(LINUX_EINVAL))
234        .then(|| mask_bytes.checked_mul(2))
235        .flatten()
236}
237
238#[cfg(any(target_os = "linux", target_os = "android"))]
239fn platform_process_cpu_affinity_count() -> Option<usize> {
240    platform_process_cpu_affinity().map(|cpus| cpus.len())
241}
242
243#[cfg(any(target_os = "linux", target_os = "android"))]
244fn platform_process_cpu_affinity() -> Option<CpuSet> {
245    unsafe extern "C" {
246        fn sched_getaffinity(pid: i32, cpusetsize: usize, mask: *mut core::ffi::c_void) -> i32;
247    }
248
249    const INITIAL_MASK_BYTES: usize = 128;
250
251    let mut mask_bytes = INITIAL_MASK_BYTES;
252    loop {
253        let mut mask = vec![0u8; mask_bytes];
254        // SAFETY: `mask` is a live allocation of `mask_bytes` bytes, and pid 0
255        // asks the OS to query the current process affinity.
256        let rc = unsafe {
257            sched_getaffinity(0, mask_bytes, mask.as_mut_ptr().cast::<core::ffi::c_void>())
258        };
259        if rc == 0 {
260            return cpu_set_from_affinity_mask(&mask);
261        }
262
263        mask_bytes = linux_next_affinity_mask_bytes(
264            mask_bytes,
265            std::io::Error::last_os_error().raw_os_error(),
266        )?;
267    }
268}
269
270#[cfg(not(any(target_os = "linux", target_os = "android")))]
271fn platform_process_cpu_affinity() -> Option<CpuSet> {
272    None
273}
274
275#[cfg(target_os = "windows")]
276fn platform_process_cpu_affinity_count() -> Option<usize> {
277    type Handle = *mut core::ffi::c_void;
278    type DwordPtr = usize;
279    type Word = u16;
280
281    unsafe extern "system" {
282        fn GetCurrentProcess() -> Handle;
283        fn GetProcessAffinityMask(
284            process: Handle,
285            process_affinity_mask: *mut DwordPtr,
286            system_affinity_mask: *mut DwordPtr,
287        ) -> i32;
288        fn GetActiveProcessorGroupCount() -> Word;
289        fn GetActiveProcessorCount(group_number: Word) -> u32;
290        fn GetProcessGroupAffinity(
291            process: Handle,
292            group_count: *mut Word,
293            group_array: *mut Word,
294        ) -> i32;
295    }
296
297    // SAFETY: `GetCurrentProcess` takes no arguments and returns a pseudo-handle
298    // owned by the process; it must not be closed by the caller.
299    let process = unsafe { GetCurrentProcess() };
300    // SAFETY: This Windows query takes no pointers and has no preconditions.
301    let system_group_count = unsafe { GetActiveProcessorGroupCount() };
302
303    if system_group_count <= 1 {
304        let mut process_mask = 0usize;
305        let mut system_mask = 0usize;
306        // SAFETY: `process` is the current-process pseudo-handle and both
307        // output pointers refer to live local variables for the duration of the call.
308        let ok = unsafe {
309            GetProcessAffinityMask(
310                process,
311                std::ptr::addr_of_mut!(process_mask),
312                std::ptr::addr_of_mut!(system_mask),
313            )
314        };
315        if ok != 0 {
316            let count = process_mask.count_ones() as usize;
317            return (count > 0).then_some(count);
318        }
319        // SAFETY: Group 0 exists when Windows reports at most one active group.
320        let count = unsafe { GetActiveProcessorCount(0) } as usize;
321        return (count > 0).then_some(count);
322    }
323
324    let mut group_count: Word = 0;
325    // SAFETY: Windows accepts a null group array to query the required processor-group
326    // count. That probe is the expected failure path: `group_count` is a live output
327    // variable and a nonzero count after a failed call is the value needed for the
328    // second call below.
329    let ok = unsafe {
330        GetProcessGroupAffinity(
331            process,
332            std::ptr::addr_of_mut!(group_count),
333            std::ptr::null_mut(),
334        )
335    };
336    if ok != 0 || group_count == 0 {
337        // SAFETY: `u16::MAX` requests the total count across all processor groups.
338        let count = unsafe { GetActiveProcessorCount(u16::MAX) } as usize;
339        return (count > 0).then_some(count);
340    }
341
342    let mut groups = vec![0u16; group_count as usize];
343    // SAFETY: `groups` has `group_count` entries and both output pointers stay
344    // valid for the duration of the call.
345    let ok = unsafe {
346        GetProcessGroupAffinity(
347            process,
348            std::ptr::addr_of_mut!(group_count),
349            groups.as_mut_ptr(),
350        )
351    };
352    if ok == 0 || group_count == 0 {
353        // SAFETY: `u16::MAX` requests the total count across all processor groups.
354        let count = unsafe { GetActiveProcessorCount(u16::MAX) } as usize;
355        return (count > 0).then_some(count);
356    }
357
358    if group_count == 1 {
359        let mut process_mask = 0usize;
360        let mut system_mask = 0usize;
361        // SAFETY: `process` is the current-process pseudo-handle and both
362        // output pointers refer to live local variables for the duration of the call.
363        let ok = unsafe {
364            GetProcessAffinityMask(
365                process,
366                std::ptr::addr_of_mut!(process_mask),
367                std::ptr::addr_of_mut!(system_mask),
368            )
369        };
370        if ok != 0 {
371            let count = process_mask.count_ones() as usize;
372            return (count > 0).then_some(count);
373        }
374    }
375
376    let count = groups
377        .into_iter()
378        .map(|group| {
379            // SAFETY: Group identifiers are returned by `GetProcessGroupAffinity`.
380            (unsafe { GetActiveProcessorCount(group) }) as usize
381        })
382        .sum();
383    (count > 0).then_some(count)
384}
385
386#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
387fn platform_process_cpu_affinity_count() -> Option<usize> {
388    None
389}
390
391#[cfg(test)]
392mod tests;