windows_topology_sys/cpu_set.rs
1// Copyright (c) 2026 Mike Grier
2//! The safe `GetSystemCpuSetInformation` walk.
3//!
4//! A second, independent view of the same processors [`crate::walk`] describes,
5//! read from a different kernel path. Windows exposes two processor-topology
6//! APIs and they are not the same API twice: this one reports **availability**
7//! (parked, and whether the processor is allocated to this process at all),
8//! Windows's **own** last-level-cache grouping, and a scheduling class and
9//! allocation tag that the relationship walk has no equivalent for.
10//!
11//! The buffer discipline matches `GetLogicalProcessorInformationEx`'s, and for
12//! the same reasons:
13//!
14//! - Size first with a null buffer, which fails with `ERROR_INSUFFICIENT_BUFFER`
15//! and reports the byte count. **The pair is attempted more than once**: the
16//! machine can grow between the sizing call and the fetch, and a hot-add there
17//! makes the fetch fail the same way. See
18//! [`SIZING_ATTEMPTS`](crate::records::SIZING_ATTEMPTS).
19//! - Records are **variable length**: advance by each record's own `Size` field,
20//! never by `size_of::<SYSTEM_CPU_SET_INFORMATION>()`. The struct's declared
21//! size describes today's `CpuSetInformation` record, and a future type may be
22//! longer.
23//! - Read every field with `read_unaligned`, since a record's start is only as
24//! aligned as the running `Size` sum makes it.
25//!
26//! # What this deliberately does not do
27//!
28//! It does not reconcile anything. `CoreIndex`, `NumaNodeIndex` and
29//! `EfficiencyClass` duplicate facts the relationship walk already reports, and
30//! the two paths can disagree -- under a hypervisor, or where one is stale.
31//! Merging them here would silently pick a winner and destroy the disagreement,
32//! which is the one thing a second observer is *for*. The records come back as
33//! what they are; deciding what to do when they differ is tracked separately.
34//!
35//! ## How the records are walked
36//!
37//! Through [`crate::records`], which both of this crate's enumerations share.
38//! Per [D-24](../DESIGN-NOTES.md#d-24) the operating system is **trusted** for
39//! the structural validity of a buffer it just wrote -- this is not a trust
40//! boundary and there is no validation pass. The walk bounds its reads because
41//! bounds are how it knows where a record ends, which is a decoding
42//! requirement rather than a defence.
43//!
44//! Two consequences a reader should not have to infer: nothing here **panics**
45//! over the shape of the buffer, because a malformed record is not evidence
46//! that this crate reached an inconsistent state; and a record that cannot be
47//! decoded is **recorded** in
48//! [`MachineMemoryTopology::enumeration_anomalies`](crate::MachineMemoryTopology::enumeration_anomalies)
49//! rather than silently dropped, so a short list is distinguishable from a
50//! small machine.
51
52use crate::EnumerationAnomaly;
53use crate::observation::Source;
54use crate::records::RecordWalk;
55use std::io;
56
57use crate::records::SIZING_ATTEMPTS;
58use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
59use windows_sys::Win32::System::SystemInformation::{
60 CpuSetInformation, GetSystemCpuSetInformation, SYSTEM_CPU_SET_INFORMATION,
61};
62use windows_sys::Win32::System::Threading::GetCurrentProcess;
63
64/// One processor as the CPU-set API describes it.
65///
66/// Field-for-field what the record carries, with the two bitfield unions
67/// decoded. Nothing is interpreted and nothing is cross-checked against the
68/// relationship walk -- see this module's note on why reconciling here would
69/// destroy the only thing a second observer is for.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub struct CpuSet {
73 /// The CPU set's id, which is what `SetThreadSelectedCpuSets` takes. Not a
74 /// processor number, and not interchangeable with one.
75 pub id: u32,
76 /// The processor group.
77 pub group: u16,
78 /// The processor's number within its group.
79 pub logical_processor_index: u8,
80 /// Windows's index for the owning core.
81 pub core_index: u8,
82 /// **Windows's own** last-level-cache grouping: processors sharing a value
83 /// share an LLC in the scheduler's view. Deliberately kept even though the
84 /// relationship walk also reports caches, because this is the OS's opinion
85 /// rather than a partition derived from firmware records, and the two are
86 /// answers to different questions.
87 pub last_level_cache_index: u8,
88 /// Windows's index for the owning NUMA node.
89 pub numa_node_index: u8,
90 /// The scheduler's efficiency class. Carried as reported, with no sentinel:
91 /// a processor absent from this enumeration has no record at all rather
92 /// than a record holding a stand-in value. Contrast
93 /// [`Processor::capacity`](crate::Processor::capacity), which uses `0` for
94 /// both "class zero" and "not known".
95 pub efficiency_class: u8,
96 /// The processor is parked, so the scheduler is currently avoiding it.
97 ///
98 /// **Measured to carry no information on Windows 11 25H2** -- see
99 /// [`Self::allocated_to_target_process`] and D-23 in `DESIGN-NOTES.md`.
100 pub parked: bool,
101 /// The processor is allocated to some process through the CPU-set API.
102 ///
103 /// **Measured to carry no information on Windows 11 25H2** -- see
104 /// [`Self::allocated_to_target_process`].
105 pub allocated: bool,
106 /// The processor is allocated **to this process** through the CPU-set API.
107 ///
108 /// # This is not "may we run here", and it is not populated
109 ///
110 /// Two corrections, both established by experiment rather than reasoning
111 /// (D-23 in `DESIGN-NOTES.md`).
112 ///
113 /// It does not mean the process may use the processor. It means the CPU set
114 /// was explicitly allocated through `SetProcessDefaultCpuSets` or
115 /// `SetThreadSelectedCpuSets`, which a process that never called them has
116 /// not done -- so `false` is the ordinary answer for an ordinary process on
117 /// every processor it is perfectly free to run on.
118 ///
119 /// And on Windows 11 25H2 (10.0.26200.9168, AMD64) it is not populated at
120 /// all. Calling `SetProcessDefaultCpuSets` successfully, and confirming
121 /// with `GetProcessDefaultCpuSets` that the allocation stuck, still leaves
122 /// the whole `AllFlags` byte reading `0x00` for every processor -- under a
123 /// null handle, the current-process pseudo-handle, and a real `OpenProcess`
124 /// handle alike.
125 ///
126 /// **So do not branch on this.** A reader of `false` is reading a byte the
127 /// kernel did not write, not a fact about the machine.
128 pub allocated_to_target_process: bool,
129 /// The processor is marked real-time.
130 ///
131 /// **Measured to carry no information on Windows 11 25H2** -- see
132 /// [`Self::allocated_to_target_process`].
133 pub real_time: bool,
134 /// The scheduling class, which shares its union with a reserved `u32`, so
135 /// confirm its meaning against current SDK documentation before relying on
136 /// it rather than treating this field as self-describing.
137 pub scheduling_class: u8,
138 /// The allocation tag.
139 pub allocation_tag: u64,
140}
141
142/// Bit positions within `AllFlags`, named rather than written inline.
143///
144/// Changing any value is a breaking change: these mirror the SDK's bitfield
145/// order, which is part of the ABI rather than this crate's choice.
146///
147/// # These are unverified, and cannot be verified from this machine
148///
149/// `AllFlags` reads `0x00` for every processor on the build measured (D-23),
150/// even after allocating CPU sets to this process -- so no bit has ever been
151/// observed set, and nothing here distinguishes a correct transcription from a
152/// wrong one. They stand on the SDK's declared order alone.
153///
154/// That is survivable only because nothing branches on them. If a consumer ever
155/// does, these need a machine that populates the byte first.
156mod flags {
157 pub(super) const PARKED: u8 = 1 << 0;
158 pub(super) const ALLOCATED: u8 = 1 << 1;
159 pub(super) const ALLOCATED_TO_TARGET_PROCESS: u8 = 1 << 2;
160 pub(super) const REAL_TIME: u8 = 1 << 3;
161}
162
163/// Enumerate the CPU sets the current process can see.
164///
165/// The current process is named explicitly, because `Process` is **not**
166/// optional in the way a null handle suggests: Microsoft documents it as the
167/// process used to compute `AllocatedToTargetProcess`, so passing null means no
168/// allocation check is made rather than "ask about me". Raised in PR #56
169/// review, where an earlier comment here claimed the opposite.
170///
171/// It makes no difference to the flags on the build this was measured against,
172/// which read zero under a null handle, the pseudo-handle, and a real
173/// `OpenProcess` handle alike -- see [`CpuSet::allocated_to_target_process`] and
174/// D-23 in `DESIGN-NOTES.md`. Asking the documented question anyway is what
175/// makes the zero a fact about the build rather than about the call.
176///
177/// # Errors
178///
179/// Returns any error from `GetSystemCpuSetInformation` other than the expected
180/// sizing failure.
181pub(crate) fn enumerate() -> io::Result<(Vec<CpuSet>, Option<EnumerationAnomaly>)> {
182 // **Sized and fetched in a bounded loop**, because the machine can change
183 // between the two calls -- see `SIZING_ATTEMPTS` for why a second
184 // `ERROR_INSUFFICIENT_BUFFER` is retried rather than returned.
185 let mut grew = None;
186 for _ in 0..SIZING_ATTEMPTS {
187 let mut length: u32 = 0;
188 // SAFETY: a null buffer with a zero length and a valid out-pointer, which
189 // is the documented sizing call. `GetCurrentProcess` is a pseudo-handle
190 // needing no close, and is the documented way to ask about this process.
191 let probe = unsafe {
192 GetSystemCpuSetInformation(
193 std::ptr::null_mut(),
194 0,
195 &raw mut length,
196 GetCurrentProcess(),
197 0,
198 )
199 };
200 if probe != 0 {
201 // Succeeding on the sizing call would mean zero bytes were needed, so
202 // there is nothing to report.
203 return Ok((Vec::new(), None));
204 }
205 let error = io::Error::last_os_error();
206 if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) {
207 return Err(error);
208 }
209 if length == 0 {
210 return Ok((Vec::new(), None));
211 }
212
213 // `u64`-backed storage for the same reason the relationship walk uses it:
214 // it guarantees 8-byte alignment for every record header regardless of what
215 // a `Vec<u8>` allocation would have happened to provide. `AllocationTag` is
216 // 8-byte-sized, so this is not merely tidiness.
217 let mut storage = vec![0_u64; (length as usize).div_ceil(8)];
218 let buffer = storage.as_mut_ptr().cast::<u8>();
219 let mut actual_length = length;
220 // SAFETY: `buffer` points to `storage`, whose byte length is at least
221 // `length` (the size the probe just reported) and is 8-byte aligned;
222 // `actual_length` is a valid in/out length pointer.
223 let ok = unsafe {
224 GetSystemCpuSetInformation(
225 buffer.cast(),
226 length,
227 &raw mut actual_length,
228 GetCurrentProcess(),
229 0,
230 )
231 };
232 if ok != 0 {
233 // SAFETY: `buffer` holds `actual_length` bytes written by the call
234 // above: consecutive `SYSTEM_CPU_SET_INFORMATION` records whose `Size`
235 // fields sum to `actual_length`, per the API's contract.
236 return Ok(unsafe { decode(buffer.cast_const(), actual_length) });
237 }
238
239 let error = io::Error::last_os_error();
240 if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) {
241 return Err(error);
242 }
243 // The machine grew between the sizing call and this one. Size again.
244 grew = Some(error);
245 }
246
247 Err(grew.expect("SIZING_ATTEMPTS is a non-zero constant, so the loop ran at least once"))
248}
249const SIZE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Size);
250const TYPE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Type);
251const UNION_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Anonymous);
252
253/// Walk `length` bytes of consecutive records.
254///
255/// # Safety
256///
257/// `base` must address `length` initialized bytes laid out as consecutive
258/// `SYSTEM_CPU_SET_INFORMATION` records.
259unsafe fn decode(base: *const u8, length: u32) -> (Vec<CpuSet>, Option<EnumerationAnomaly>) {
260 // Offsets within the `CpuSet` arm of the record's union, computed from the
261 // generated types so a binding change moves them rather than silently
262 // shifting what is read.
263 use windows_sys::Win32::System::SystemInformation::{
264 SYSTEM_CPU_SET_INFORMATION_0, SYSTEM_CPU_SET_INFORMATION_0_0,
265 };
266 const CPUSET_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION_0, CpuSet);
267 macro_rules! field {
268 ($name:ident) => {
269 UNION_OFFSET
270 + CPUSET_OFFSET
271 + core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION_0_0, $name)
272 };
273 }
274
275 // SAFETY: forwarded from this function's own contract.
276 let mut walk = unsafe {
277 RecordWalk::new(
278 base,
279 length,
280 SIZE_OFFSET,
281 size_of::<SYSTEM_CPU_SET_INFORMATION>(),
282 Source::CpuSets,
283 )
284 };
285
286 let mut records = Vec::new();
287 for record in &mut walk {
288 // Every read below is bounded by the record's own `Size`, and the walk
289 // has already established that `Size` covers a full
290 // `SYSTEM_CPU_SET_INFORMATION` -- so none of these can fail. They are
291 // written as options rather than asserted because the alternative to a
292 // `None` that skips a record is a panic, and per D-24 this crate does
293 // not panic over the shape of someone else's buffer.
294 // SAFETY: the walk yielded a record addressing `size` initialized bytes.
295 let decoded = unsafe {
296 (|| {
297 if record.read::<i32>(TYPE_OFFSET)? != CpuSetInformation {
298 return None;
299 }
300 let all_flags = record.read::<u8>(field!(Anonymous1))?;
301 Some(CpuSet {
302 id: record.read(field!(Id))?,
303 group: record.read(field!(Group))?,
304 logical_processor_index: record.read(field!(LogicalProcessorIndex))?,
305 core_index: record.read(field!(CoreIndex))?,
306 last_level_cache_index: record.read(field!(LastLevelCacheIndex))?,
307 numa_node_index: record.read(field!(NumaNodeIndex))?,
308 efficiency_class: record.read(field!(EfficiencyClass))?,
309 parked: all_flags & flags::PARKED != 0,
310 allocated: all_flags & flags::ALLOCATED != 0,
311 allocated_to_target_process: all_flags & flags::ALLOCATED_TO_TARGET_PROCESS
312 != 0,
313 real_time: all_flags & flags::REAL_TIME != 0,
314 scheduling_class: record.read(field!(Anonymous2))?,
315 allocation_tag: record.read(field!(AllocationTag))?,
316 })
317 })()
318 };
319 if let Some(cpu_set) = decoded {
320 records.push(cpu_set);
321 }
322 }
323
324 (records, walk.anomaly())
325}
326
327#[cfg(test)]
328mod tests;