win_numa_sys/node.rs
1// Copyright (c) 2026 Mike Grier
2//! [`NumaNode`], and the two questions Windows will answer about nodes.
3
4use std::io;
5use std::os::windows::io::RawHandle;
6
7use windows_sys::Win32::Foundation::HANDLE;
8use windows_sys::Win32::System::IO::DeviceIoControl;
9use windows_sys::Win32::System::Ioctl::FSCTL_QUERY_VOLUME_NUMA_INFO;
10use windows_sys::Win32::System::Threading::GetNumaHighestNodeNumber;
11
12/// A NUMA node number, as Windows numbers them.
13///
14/// A newtype rather than a bare `u32` because the two `u32`s in this area mean
15/// different things and are trivially swapped at a call site: a node *number*
16/// and a *count* of nodes are both small integers, and
17/// [`highest_numa_node`] returns the former while reading like the latter.
18///
19/// **Node numbers are not an index.** Windows does not promise they run
20/// `0..n`, so a caller deriving a node from a position in some list is making
21/// an assumption the platform never offered -- see `windows-placement-probe`,
22/// where a positional index reaching `VirtualAllocExNuma` was a real defect and
23/// is called out at the site that fixed it.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub struct NumaNode(u32);
26
27impl NumaNode {
28 /// Wrap a raw node number.
29 ///
30 /// Not validated against the machine: `VirtualAllocExNuma` is where an
31 /// impossible node is rejected, and validating here would mean a second,
32 /// weaker opinion about what exists. See [`NumaBuffer::new`] for what the
33 /// platform actually does with an out-of-range node, which is not what its
34 /// documentation says.
35 ///
36 /// [`NumaBuffer::new`]: crate::NumaBuffer::new
37 #[must_use]
38 pub const fn new(node: u32) -> Self {
39 Self(node)
40 }
41
42 /// The raw node number, for handing to a Win32 call.
43 #[must_use]
44 pub const fn get(self) -> u32 {
45 self.0
46 }
47}
48
49impl std::fmt::Display for NumaNode {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(f, "node {}", self.0)
52 }
53}
54
55/// The highest node number this machine reports, if it answers.
56///
57/// `GetNumaHighestNodeNumber`. The value is a *node number*, not a count: a
58/// machine with one node answers `NumaNode(0)`, not one.
59///
60/// # Why a caller wants this
61///
62/// Mostly to qualify a report rather than to drive a choice. On a machine that
63/// answers `NumaNode(0)` there is exactly one node, so every placement decision
64/// is the same decision, and a line saying "placed on node 0" is true and
65/// misleading. Asking this is how a caller can say which of those it is.
66///
67/// # Errors
68///
69/// `None` when the call fails, which is reported as "unknown" rather than as a
70/// node count of zero -- a machine that will not say how many nodes it has is a
71/// different thing from a machine with none.
72#[must_use]
73pub fn highest_numa_node() -> Option<NumaNode> {
74 let mut highest: u32 = 0;
75 // SAFETY: the out parameter is a live local for the call's duration.
76 let ok = unsafe { GetNumaHighestNodeNumber(&raw mut highest) };
77 (ok != 0).then_some(NumaNode::new(highest))
78}
79
80/// The NUMA node `handle`'s **volume** reports, if it reports one.
81///
82/// `FSCTL_QUERY_VOLUME_NUMA_INFO`, issued against the handle directly -- the
83/// documented control code accepts a file or directory handle, so this needs no
84/// device-tree walk and no second open.
85///
86/// # This answers a question about a volume, not about a file
87///
88/// The documented meaning is the node the *volume* resides on. A volume may
89/// span several devices -- an ordinary spanned volume or a Storage Spaces set
90/// does -- and then a single reported node is a fiction rather than an answer,
91/// because the file's extents may live anywhere across the set. **It therefore
92/// cannot tell a caller which node a particular file's I/O is closest to**, even
93/// when it succeeds.
94///
95/// That is a limit of the question, not of this wrapper, and it is why this
96/// function reports rather than acts: a caller who knows their storage layout
97/// can decide what the answer is worth, and one who does not should not have a
98/// placement chosen for them on the strength of it.
99///
100/// # Errors
101///
102/// Any error from `DeviceIoControl`, and
103/// [`io::ErrorKind::InvalidData`] if the control code returns a payload that is
104/// not the documented single `u32`.
105pub fn volume_numa_node(handle: RawHandle) -> io::Result<NumaNode> {
106 let mut node: u32 = u32::MAX;
107 let mut returned: u32 = 0;
108 // SAFETY: `handle` is the caller's, and outlives this call by the contract
109 // on this function. The FSCTL takes no input buffer, and its output is
110 // documented as `FSCTL_QUERY_VOLUME_NUMA_INFO_OUTPUT { ULONG NumaNode }` --
111 // one `u32`, which is what `node` provides and what the size argument says.
112 let ok = unsafe {
113 DeviceIoControl(
114 handle as HANDLE,
115 FSCTL_QUERY_VOLUME_NUMA_INFO,
116 std::ptr::null(),
117 0,
118 (&raw mut node).cast::<std::ffi::c_void>(),
119 u32::try_from(size_of::<u32>()).expect("four fits in a u32"),
120 &raw mut returned,
121 std::ptr::null_mut(),
122 )
123 };
124 if ok == 0 {
125 return Err(io::Error::last_os_error());
126 }
127 if returned as usize != size_of::<u32>() {
128 return Err(io::Error::new(
129 io::ErrorKind::InvalidData,
130 format!(
131 "FSCTL_QUERY_VOLUME_NUMA_INFO returned {returned} bytes, not {}",
132 size_of::<u32>()
133 ),
134 ));
135 }
136 Ok(NumaNode::new(node))
137}
138
139#[cfg(test)]
140mod tests;