numa_shim/lib.rs
1//! `numa-shim` — dependency-free NUMA detection and binding.
2//!
3//! **Key selling point:** zero C library dependencies.
4//! - Linux: `mbind(2)` via raw `syscall(2)` (no libnuma, no hwloc).
5//! - Linux node detection: reads `/sys/devices/system/node/nodeN/cpumap` directly
6//! via `open`/`read`/`close` from the C runtime (always present in glibc/musl).
7//! - Windows: `VirtualAllocExNuma` for NUMA-preferred reservations;
8//! `GetCurrentProcessorNumberEx` + `GetNumaProcessorNodeEx` for detection.
9//! - macOS / miri: no-op (no public NUMA API on macOS; miri has no real OS topology).
10//!
11//! This is rare in the Rust ecosystem — typical NUMA crates bind to `libnuma` or
12//! `hwloc`, pulling in heavy C dependencies. `numa-shim` has **zero non-system
13//! dependencies** in its default configuration.
14//!
15//! ## Usage
16//!
17//! ```rust
18//! use numa_shim::{current_node, NO_NODE};
19//!
20//! match current_node() {
21//! Some(node) => println!("Running on NUMA node {node}"),
22//! None => println!("NUMA unavailable or single-node host"),
23//! }
24//! ```
25//!
26//! ## Feature flags
27//!
28//! | Flag | Effect |
29//! |------|--------|
30//! | `vmem-integration` | Enables [`reserve_on_node`], which uses [`aligned-vmem`] for the reservation step. Windows path uses `VirtualAllocExNuma`; Linux reserves then calls `mbind`. |
31//!
32//! ## Platform matrix
33//!
34//! | Platform | [`current_node`] | [`bind_range`] | [`reserve_on_node`] (feature) |
35//! |----------|-----------------|----------------|-------------------------------|
36//! | Linux x86_64/aarch64 (non-miri) | sched_getcpu + sysfs cpumap | `mbind(2)` via syscall | mmap then mbind |
37//! | Linux other arch (non-miri) | sched_getcpu + sysfs cpumap | no-op | mmap (no mbind) |
38//! | Windows (non-miri) | `GetCurrentProcessorNumberEx` | no-op (use `reserve_on_node`) | `VirtualAllocExNuma` (direct, via `Reservation::from_raw_parts`) |
39//! | macOS | `None` | no-op | `reserve_aligned` (no binding) |
40//! | miri | `None` | no-op | `reserve_aligned` (no binding) |
41//! | other | `None` | no-op | `reserve_aligned` (no binding) |
42
43// This crate intentionally contains unsafe OS FFI code.
44// The public API is safe; all unsafe is confined to platform modules and
45// clearly documented with // SAFETY: proof comments.
46#![allow(unsafe_code)]
47#![deny(missing_docs)]
48
49/// Sentinel value meaning "no NUMA node / feature disabled / unsupported
50/// platform". This constant is useful when interfacing with APIs that return
51/// a raw `u32` node index and need a "not available" sentinel.
52///
53/// [`current_node`] returns `None` instead of this sentinel; `NO_NODE` is
54/// provided for interop with code that uses the sentinel pattern.
55pub const NO_NODE: u32 = u32::MAX;
56
57/// Test-only mock state replacing platform NUMA syscalls. Records every
58/// invocation into a thread-local buffer so unit tests can assert the
59/// wrapping logic is correct on any target (including macOS and miri,
60/// where real NUMA syscalls are absent).
61///
62/// Enabled by feature `mock`. When enabled, the public NUMA functions
63/// dispatch into this module instead of the platform implementations.
64#[cfg(feature = "mock")]
65pub mod mock {
66 use core::cell::RefCell;
67
68 /// One recorded invocation of a public NUMA function.
69 #[derive(Debug, Clone, PartialEq, Eq)]
70 pub enum MockCall {
71 /// `current_node()` was called; the inner value is what was returned.
72 CurrentNode(u32),
73 /// `bind_range(base, len, node)` was called (past the short-circuit).
74 BindRange {
75 /// Base address passed to `bind_range`, as `usize`.
76 base: usize,
77 /// Length in bytes passed to `bind_range`.
78 len: usize,
79 /// NUMA node id passed to `bind_range`.
80 node: u32,
81 },
82 /// `reserve_on_node(size, align, node)` was called.
83 ReserveOnNode {
84 /// Requested reservation size in bytes.
85 size: usize,
86 /// Required alignment in bytes.
87 align: usize,
88 /// NUMA node id passed to `reserve_on_node`.
89 node: u32,
90 },
91 }
92
93 std::thread_local! {
94 /// Calls recorded since the last `drain()`.
95 pub static CALLS: RefCell<Vec<MockCall>> = const { RefCell::new(Vec::new()) };
96 /// Value returned by `current_node()` under the mock. Default 0.
97 pub static CURRENT_NODE_SLOT: RefCell<u32> = const { RefCell::new(0) };
98 }
99
100 /// Drain every recorded call since the last drain (or test start).
101 pub fn drain() -> Vec<MockCall> {
102 CALLS.with(|c| c.borrow_mut().drain(..).collect())
103 }
104
105 /// Set the value the next `current_node()` call will return.
106 pub fn set_current_node(node: u32) {
107 CURRENT_NODE_SLOT.with(|c| *c.borrow_mut() = node);
108 }
109
110 /// Internal: read the scripted current_node value.
111 pub(crate) fn current_node_slot() -> u32 {
112 CURRENT_NODE_SLOT.with(|c| *c.borrow())
113 }
114
115 /// Internal: record a call.
116 pub(crate) fn record(call: MockCall) {
117 CALLS.with(|c| c.borrow_mut().push(call));
118 }
119}
120
121/// Return the NUMA node id of the calling thread, or `None` if not
122/// determinable.
123///
124/// Returns `None` when:
125/// - The platform does not provide a NUMA API (macOS, miri, unsupported OS).
126/// - The OS API returns an error (e.g. single-NUMA host with disabled NUMA
127/// support in the kernel).
128/// - The CPU index cannot be mapped to a NUMA node via sysfs.
129///
130/// On a single-node Linux system where sysfs NUMA files are absent, this
131/// function returns `Some(0)` (all CPUs are on node 0).
132#[must_use]
133pub fn current_node() -> Option<u32> {
134 #[cfg(feature = "mock")]
135 {
136 let n = mock::current_node_slot();
137 mock::record(mock::MockCall::CurrentNode(n));
138 return Some(n);
139 }
140 #[cfg(not(feature = "mock"))]
141 {
142 let raw = platform::current_node_impl();
143 if raw == NO_NODE {
144 None
145 } else {
146 Some(raw)
147 }
148 }
149}
150
151/// Bind the virtual-memory range `[base, base + len)` to NUMA node `node`.
152///
153/// On Linux (x86_64 and aarch64): issues `mbind(2)` via the `syscall(2)`
154/// libc wrapper with `MPOL_PREFERRED` (soft preference — the kernel falls
155/// back to any node on memory pressure). This steers physical page allocation
156/// to `node` at the first page-fault after the call.
157///
158/// On Windows: no-op. Windows has no post-reserve NUMA binding API; use
159/// [`reserve_on_node`] (with the `vmem-integration` feature) to bind at
160/// reservation time via `VirtualAllocExNuma`.
161///
162/// On macOS / miri / other: no-op.
163///
164/// The function silently ignores OS errors (e.g. `EINVAL` on a single-node
165/// kernel): the allocation is always valid regardless of whether binding
166/// succeeded.
167///
168/// # Safety
169///
170/// `[base, base + len)` must be a valid OS reservation owned exclusively by
171/// the caller for the duration of the call. The function never reads or writes
172/// payload bytes — it only passes the range to `mbind(2)` which sets kernel
173/// page-policy metadata.
174pub unsafe fn bind_range(base: *mut u8, len: usize, node: u32) {
175 if node == NO_NODE || len == 0 {
176 return;
177 }
178 #[cfg(feature = "mock")]
179 {
180 mock::record(mock::MockCall::BindRange {
181 base: base as usize,
182 len,
183 node,
184 });
185 return;
186 }
187 #[cfg(not(feature = "mock"))]
188 {
189 // SAFETY: caller guarantees [base, base+len) is a valid OS reservation.
190 platform::bind_range_impl(base, len, node);
191 }
192}
193
194/// Reserve `size` bytes of anonymous virtual memory with a NUMA preference for
195/// `node`, aligned to `align`.
196///
197/// Requires the `vmem-integration` feature.
198///
199/// - Linux: reserves via [`aligned_vmem::reserve_aligned`] then calls
200/// [`bind_range`] before the first page-fault.
201/// - Windows: calls `VirtualAllocExNuma` directly (the only way to get NUMA
202/// binding on Windows is at reservation time).
203/// - macOS / miri / other: falls back to [`aligned_vmem::reserve_aligned`]
204/// without NUMA binding.
205///
206/// Returns `None` on OOM or if `size`/`align` violate [`aligned_vmem`]
207/// contracts (size non-zero, align a power-of-two `>=` page size, size a
208/// multiple of page size).
209///
210/// When `node` is `NO_NODE` (or [`None`] from [`current_node`]) the call
211/// behaves like plain [`aligned_vmem::reserve_aligned`].
212#[cfg(feature = "vmem-integration")]
213#[must_use]
214pub fn reserve_on_node(size: usize, align: usize, node: u32) -> Option<aligned_vmem::Reservation> {
215 #[cfg(feature = "mock")]
216 {
217 mock::record(mock::MockCall::ReserveOnNode { size, align, node });
218 // Still chain to aligned_vmem so the test can verify the Reservation works.
219 let r = aligned_vmem::reserve_aligned(size, align)?;
220 if node != NO_NODE {
221 let base = r.as_ptr();
222 let len = r.len();
223 mock::record(mock::MockCall::BindRange {
224 base: base as usize,
225 len,
226 node,
227 });
228 }
229 return Some(r);
230 }
231 #[cfg(not(feature = "mock"))]
232 {
233 platform::reserve_on_node_impl(size, align, node)
234 }
235}
236
237// ---------------------------------------------------------------------------
238// Per-platform implementations
239// ---------------------------------------------------------------------------
240
241// ---- Linux (real hardware, not miri) --------------------------------------
242#[cfg(all(target_os = "linux", not(miri)))]
243mod platform {
244 use super::{bind_range_impl_linux, NO_NODE};
245
246 pub(super) fn current_node_impl() -> u32 {
247 // SAFETY: `sched_getcpu` is a POSIX function that returns the CPU index
248 // of the calling thread, or -1 on error. No pointer arguments.
249 let cpu = unsafe { libc_sched_getcpu() };
250 if cpu < 0 {
251 return NO_NODE;
252 }
253 cpu_to_numa_node(cpu as u32)
254 }
255
256 pub(super) fn bind_range_impl(base: *mut u8, len: usize, node: u32) {
257 // SAFETY: caller of bind_range is `unsafe fn` and guarantees
258 // `[base, base+len)` is a live OS reservation owned by it. mbind only
259 // sets kernel page-policy metadata, never reads/writes payload bytes.
260 unsafe { bind_range_impl_linux(base, len, node) };
261 }
262
263 #[cfg(feature = "vmem-integration")]
264 pub(super) fn reserve_on_node_impl(
265 size: usize,
266 align: usize,
267 node: u32,
268 ) -> Option<aligned_vmem::Reservation> {
269 // Reserve via aligned-vmem, then bind with mbind before first page access.
270 let r = aligned_vmem::reserve_aligned(size, align)?;
271 if node != NO_NODE {
272 let base = r.as_ptr();
273 let len = r.len();
274 // SAFETY: `r` is a valid live OS reservation we own; `base` and
275 // `len` come from the freshly-created Reservation. mbind only sets
276 // kernel page-policy metadata, never reads/writes payload bytes.
277 unsafe { bind_range_impl_linux(base, len, node) };
278 }
279 Some(r)
280 }
281
282 /// Map a CPU index to its NUMA node by reading
283 /// `/sys/devices/system/node/nodeN/cpumap` for each node N.
284 ///
285 /// Returns `0` when sysfs NUMA topology files are absent (single-node
286 /// system where the kernel didn't compile NUMA support).
287 fn cpu_to_numa_node(cpu_idx: u32) -> u32 {
288 // Try up to 64 NUMA nodes (reasonable upper bound for current hardware).
289 for node in 0u32..64 {
290 if node_contains_cpu(node, cpu_idx) {
291 return node;
292 }
293 }
294 // Single-node system or NUMA sysfs not present: treat as node 0.
295 // mbind to node 0 on a single-node machine is a no-op from the OS
296 // perspective (pages are already on node 0).
297 0
298 }
299
300 /// Return `true` if `node` lists `cpu_idx` in its cpumap.
301 ///
302 /// Reads `/sys/devices/system/node/nodeN/cpumap`, a comma-separated list
303 /// of hex 32-bit words (most-significant word first) encoding a CPU bitmask.
304 fn node_contains_cpu(node: u32, cpu_idx: u32) -> bool {
305 let mut path = [0u8; 64];
306 let path_str = format_sysfs_path(&mut path, node);
307 read_cpumap_contains_cpu(path_str, cpu_idx)
308 }
309
310 /// Write `/sys/devices/system/node/nodeN/cpumap\0` into `buf` and return
311 /// the nul-terminated slice. Avoids heap allocation.
312 fn format_sysfs_path(buf: &mut [u8; 64], node: u32) -> &[u8] {
313 const PREFIX: &[u8] = b"/sys/devices/system/node/node";
314 const SUFFIX: &[u8] = b"/cpumap\0";
315 let mut pos = 0usize;
316 for &b in PREFIX {
317 buf[pos] = b;
318 pos += 1;
319 }
320 // Write decimal digits of `node` (up to 3 digits for node < 1000).
321 let mut tmp = [0u8; 4];
322 let mut n = node;
323 let mut digits = 0usize;
324 if n == 0 {
325 tmp[0] = b'0';
326 digits = 1;
327 } else {
328 while n > 0 {
329 tmp[digits] = b'0' + (n % 10) as u8;
330 n /= 10;
331 digits += 1;
332 }
333 // Written in reverse; fix ordering.
334 tmp[..digits].reverse();
335 }
336 for i in 0..digits {
337 buf[pos] = tmp[i];
338 pos += 1;
339 }
340 for &b in SUFFIX {
341 buf[pos] = b;
342 pos += 1;
343 }
344 &buf[..pos]
345 }
346
347 /// Open the cpumap file at `path` and check if `cpu_idx` bit is set.
348 ///
349 /// The cpumap file format: `"00000000,00000001\n"` — comma-separated
350 /// hex 32-bit words, most-significant word first; each word covers 32 CPUs.
351 fn read_cpumap_contains_cpu(path: &[u8], cpu_idx: u32) -> bool {
352 // SAFETY: `path` is a valid nul-terminated C string constructed above.
353 // `open` is a POSIX syscall; we check for -1 on error.
354 let fd = unsafe { libc_open(path.as_ptr() as *const core::ffi::c_char, 0) };
355 if fd < 0 {
356 return false;
357 }
358 let mut buf = [0u8; 256];
359 // SAFETY: `buf` is a valid writable buffer of length 256; `fd` was
360 // returned by a successful `open` call above.
361 let n = unsafe { libc_read(fd, buf.as_mut_ptr() as *mut core::ffi::c_void, 256) };
362 // SAFETY: `fd` was opened by us and must be closed exactly once.
363 unsafe { libc_close(fd) };
364 if n <= 0 {
365 return false;
366 }
367 parse_cpumap_contains_cpu(&buf[..n as usize], cpu_idx)
368 }
369
370 /// Parse a Linux cpumap bitmask string and test whether `cpu_idx` is set.
371 ///
372 /// Format: comma-separated hex 32-bit words, most-significant first,
373 /// optional trailing newline. Example: `"00000000,00000003\n"` means
374 /// CPUs 0 and 1 are in this node.
375 fn parse_cpumap_contains_cpu(data: &[u8], cpu_idx: u32) -> bool {
376 let data = trim_end(data);
377 let word_count = data.iter().filter(|&&b| b == b',').count() + 1;
378 let target_word = (cpu_idx / 32) as usize;
379 let bit_in_word = cpu_idx % 32;
380 if target_word >= word_count {
381 return false;
382 }
383 // The leftmost word covers the highest CPU indices.
384 let left_index = word_count - 1 - target_word;
385 let word_str = match nth_token(data, left_index, b',') {
386 Some(s) => s,
387 None => return false,
388 };
389 let val = match parse_hex_u32(word_str) {
390 Some(v) => v,
391 None => return false,
392 };
393 (val >> bit_in_word) & 1 == 1
394 }
395
396 fn trim_end(data: &[u8]) -> &[u8] {
397 let mut end = data.len();
398 while end > 0 && (data[end - 1] == b'\n' || data[end - 1] == b'\r' || data[end - 1] == b' ')
399 {
400 end -= 1;
401 }
402 &data[..end]
403 }
404
405 /// Return the `n`-th token (0-indexed) delimited by `sep`.
406 fn nth_token(data: &[u8], n: usize, sep: u8) -> Option<&[u8]> {
407 let mut idx = 0usize;
408 let mut start = 0usize;
409 for (i, &b) in data.iter().enumerate() {
410 if b == sep {
411 if idx == n {
412 return Some(&data[start..i]);
413 }
414 idx += 1;
415 start = i + 1;
416 }
417 }
418 // Last token (no trailing separator).
419 if idx == n {
420 Some(&data[start..])
421 } else {
422 None
423 }
424 }
425
426 /// Parse a hex string (no `0x` prefix) as `u32`. Returns `None` on error.
427 fn parse_hex_u32(s: &[u8]) -> Option<u32> {
428 if s.is_empty() {
429 return None;
430 }
431 let mut val: u32 = 0;
432 for &b in s {
433 let digit = match b {
434 b'0'..=b'9' => b - b'0',
435 b'a'..=b'f' => b - b'a' + 10,
436 b'A'..=b'F' => b - b'A' + 10,
437 _ => return None,
438 };
439 val = val.wrapping_shl(4) | digit as u32;
440 }
441 Some(val)
442 }
443
444 // -- Raw Linux FFI (no libc crate dependency) ----------------------------
445
446 extern "C" {
447 fn sched_getcpu() -> core::ffi::c_int;
448 fn open(path: *const core::ffi::c_char, flags: core::ffi::c_int, ...) -> core::ffi::c_int;
449 fn read(
450 fd: core::ffi::c_int,
451 buf: *mut core::ffi::c_void,
452 count: usize,
453 ) -> core::ffi::c_long;
454 fn close(fd: core::ffi::c_int) -> core::ffi::c_int;
455 }
456
457 // Thin private wrappers so every call site has its own // SAFETY: comment.
458 unsafe fn libc_sched_getcpu() -> core::ffi::c_int {
459 // SAFETY: no pointer args; returns current CPU index or -1.
460 sched_getcpu()
461 }
462 unsafe fn libc_open(
463 path: *const core::ffi::c_char,
464 flags: core::ffi::c_int,
465 ) -> core::ffi::c_int {
466 // SAFETY: caller must supply a valid nul-terminated path.
467 open(path, flags)
468 }
469 unsafe fn libc_read(
470 fd: core::ffi::c_int,
471 buf: *mut core::ffi::c_void,
472 count: usize,
473 ) -> core::ffi::c_long {
474 // SAFETY: caller must supply a valid fd and a writable buffer of `count` bytes.
475 read(fd, buf, count)
476 }
477 unsafe fn libc_close(fd: core::ffi::c_int) {
478 // SAFETY: caller must supply a valid, open fd that is closed exactly once.
479 let _ = close(fd);
480 }
481}
482
483// ---------------------------------------------------------------------------
484// Linux mbind: factored out of `platform` so both bind_range_impl and
485// reserve_on_node_impl (under vmem-integration) can call it.
486// ---------------------------------------------------------------------------
487
488/// Bind `[base, base+len)` to NUMA node `node` via `mbind(2)`.
489///
490/// Uses `syscall(SYS_MBIND, …)` — avoids a hard dependency on `libnuma`.
491/// OS errors (e.g. `EINVAL` on a single-node kernel) are silently discarded.
492#[cfg(all(
493 target_os = "linux",
494 not(miri),
495 any(target_arch = "x86_64", target_arch = "aarch64")
496))]
497unsafe fn bind_range_impl_linux(base: *mut u8, len: usize, node: u32) {
498 if node == NO_NODE || node >= 64 {
499 return;
500 }
501 // 64-bit nodemask with bit `node` set.
502 let nodemask: u64 = 1u64 << node;
503 let maxnode: u64 = 64;
504 // SAFETY: `base` is the start of a live OS reservation (caller's contract).
505 // `mbind` only sets kernel page-policy metadata; it never accesses payload
506 // bytes. Errors are silently discarded — the allocation is correct regardless.
507 libc_mbind(
508 base as *mut core::ffi::c_void,
509 len as u64,
510 MPOL_PREFERRED,
511 &nodemask as *const u64,
512 maxnode,
513 0,
514 );
515}
516
517/// No-op on Linux architectures without a known `SYS_MBIND` number.
518#[cfg(all(
519 target_os = "linux",
520 not(miri),
521 not(any(target_arch = "x86_64", target_arch = "aarch64"))
522))]
523unsafe fn bind_range_impl_linux(_base: *mut u8, _len: usize, _node: u32) {
524 // mbind syscall number unknown for this arch; binding is skipped silently.
525}
526
527/// `MPOL_PREFERRED`: soft preferred-node policy; kernel falls back on pressure.
528#[cfg(all(target_os = "linux", not(miri)))]
529const MPOL_PREFERRED: i32 = 1;
530
531/// Syscall number for `mbind(2)` on x86_64.
532#[cfg(all(target_os = "linux", not(miri), target_arch = "x86_64"))]
533const SYS_MBIND: i64 = 237;
534
535/// Syscall number for `mbind(2)` on aarch64.
536#[cfg(all(target_os = "linux", not(miri), target_arch = "aarch64"))]
537const SYS_MBIND: i64 = 235;
538
539/// `syscall(2)` from glibc/musl — always present, does not require libnuma.
540#[cfg(all(
541 target_os = "linux",
542 not(miri),
543 any(target_arch = "x86_64", target_arch = "aarch64")
544))]
545extern "C" {
546 fn syscall(number: i64, ...) -> i64;
547}
548
549#[cfg(all(
550 target_os = "linux",
551 not(miri),
552 any(target_arch = "x86_64", target_arch = "aarch64")
553))]
554unsafe fn libc_mbind(
555 addr: *mut core::ffi::c_void,
556 len: u64,
557 mode: i32,
558 nodemask: *const u64,
559 maxnode: u64,
560 flags: u32,
561) -> i64 {
562 // SAFETY: SYS_MBIND is the correct syscall number for this architecture.
563 // `addr` is a live mapping; `nodemask` points to a valid stack-allocated u64.
564 // Errors are ignored by the caller.
565 syscall(
566 SYS_MBIND,
567 addr,
568 len as usize,
569 mode as i64,
570 nodemask,
571 maxnode as usize,
572 flags as i64,
573 )
574}
575
576// ---------------------------------------------------------------------------
577// Windows platform module
578// ---------------------------------------------------------------------------
579#[cfg(all(windows, not(miri)))]
580mod platform {
581 use super::NO_NODE;
582
583 pub(super) fn current_node_impl() -> u32 {
584 let mut proc_num = ProcessorNumber {
585 group: 0,
586 number: 0,
587 reserved: 0,
588 };
589 // SAFETY: `proc_num` is a valid zeroed `PROCESSOR_NUMBER`; this API
590 // fills it in and never fails (documented to always succeed).
591 unsafe { GetCurrentProcessorNumberEx(&mut proc_num) };
592
593 let mut node: u16 = 0;
594 // SAFETY: `proc_num` was filled by `GetCurrentProcessorNumberEx`;
595 // `GetNumaProcessorNodeEx` maps it to a NUMA node (returns 0 on
596 // single-node or error, which we remap to NO_NODE).
597 let ok = unsafe { GetNumaProcessorNodeEx(&proc_num, &mut node) };
598 if ok == 0 {
599 return NO_NODE;
600 }
601 node as u32
602 }
603
604 /// On Windows there is no post-reserve NUMA binding API equivalent to
605 /// Linux `mbind(2)`. Binding must happen at reservation time via
606 /// `VirtualAllocExNuma`. This function is intentionally a no-op.
607 pub(super) fn bind_range_impl(_base: *mut u8, _len: usize, _node: u32) {
608 // no-op: Windows has no post-mmap NUMA rebind. Use reserve_on_node.
609 }
610
611 #[cfg(feature = "vmem-integration")]
612 pub(super) fn reserve_on_node_impl(
613 size: usize,
614 align: usize,
615 node: u32,
616 ) -> Option<aligned_vmem::Reservation> {
617 if node == NO_NODE {
618 // No NUMA preference: fall back to ordinary aligned-vmem reserve.
619 return aligned_vmem::reserve_aligned(size, align);
620 }
621 reserve_aligned_numa(size, align, node)
622 }
623
624 /// Reserve `size` bytes aligned to `align` with a NUMA preference for `node`
625 /// via `VirtualAllocExNuma` directly. This is the **only** way to bind
626 /// memory to a NUMA node on Windows — there is no post-reservation
627 /// equivalent to Linux `mbind(2)`.
628 ///
629 /// Strategy (mirrors `aligned-vmem`'s own Windows reservation): over-reserve
630 /// `size + align` bytes via `VirtualAllocExNuma`, find the aligned chunk
631 /// inside, and adopt the WHOLE reservation into an `aligned_vmem::Reservation`
632 /// via [`aligned_vmem::Reservation::from_raw_parts`]. The handle's `Drop` /
633 /// release path will `VirtualFree(MEM_RELEASE)` the entire over-reserved
634 /// span exactly once.
635 ///
636 /// Returns `None` on contract violation (`align` not a power of two `>= PAGE`,
637 /// `size` zero or not a multiple of `PAGE`) or when the OS refuses the
638 /// reservation (OOM / no memory on the requested node).
639 #[cfg(feature = "vmem-integration")]
640 fn reserve_aligned_numa(
641 size: usize,
642 align: usize,
643 node: u32,
644 ) -> Option<aligned_vmem::Reservation> {
645 use aligned_vmem::PAGE;
646 if size == 0 || !align.is_power_of_two() || align < PAGE || size % PAGE != 0 {
647 return None;
648 }
649 let over = size.checked_add(align)?;
650
651 // SAFETY: `VirtualAllocExNuma(GetCurrentProcess(), NULL, over,
652 // MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE, node)` reserves+commits
653 // `over` bytes on (preferred) `node`, returning the base or NULL.
654 // We treat NULL as OOM and bail.
655 let raw = unsafe {
656 VirtualAllocExNuma(
657 GetCurrentProcess(),
658 core::ptr::null_mut(),
659 over,
660 MEM_RESERVE | MEM_COMMIT,
661 PAGE_READWRITE,
662 node,
663 )
664 };
665 if raw.is_null() {
666 return None;
667 }
668 let raw_u = raw as usize;
669 let base_u = (raw_u + align - 1) & !(align - 1);
670 let base = base_u as *mut u8;
671
672 // SAFETY of from_raw_parts:
673 // - `base` is non-null, valid for `size` bytes (it's inside the
674 // `over`-byte reservation since `align <= over - size`), aligned
675 // to `align` (by construction above).
676 // - `raw` is the start of the OS reservation, non-null.
677 // - `over = size + align` is the full reservation length, multiple of PAGE.
678 // - `align` was just used to align `base` — same value.
679 // - The reservation will be released exactly once when the returned
680 // handle's `Drop` fires (or via `release` after `into_parts`).
681 // - The reservation was created with `MEM_RESERVE | MEM_COMMIT` →
682 // `VirtualFree(MEM_RELEASE)` will accept it.
683 let r = unsafe {
684 aligned_vmem::Reservation::from_raw_parts(base, size, raw as *mut u8, over, align)
685 };
686 Some(r)
687 }
688
689 /// Mirrors `PROCESSOR_NUMBER` from the Windows SDK.
690 #[repr(C)]
691 struct ProcessorNumber {
692 group: u16,
693 number: u8,
694 reserved: u8,
695 }
696
697 extern "system" {
698 fn GetCurrentProcessorNumberEx(proc_number: *mut ProcessorNumber);
699 fn GetNumaProcessorNodeEx(processor: *const ProcessorNumber, node_number: *mut u16) -> i32;
700 fn GetCurrentProcess() -> *mut core::ffi::c_void;
701 }
702
703 // `VirtualAllocExNuma` is the load-bearing call: it is the ONLY way to
704 // bind a reservation to a NUMA node on Windows (`VirtualAlloc` chooses
705 // the node by kernel heuristic; there is no `mbind`-equivalent for
706 // post-reservation binding). Declared locally to avoid pulling
707 // `windows-sys` / `winapi` just for one syscall.
708 #[cfg(feature = "vmem-integration")]
709 extern "system" {
710 fn VirtualAllocExNuma(
711 h_process: *mut core::ffi::c_void,
712 lp_address: *mut core::ffi::c_void,
713 dw_size: usize,
714 fl_allocation_type: u32,
715 fl_protect: u32,
716 nnd_preferred: u32,
717 ) -> *mut core::ffi::c_void;
718 }
719
720 #[cfg(feature = "vmem-integration")]
721 const MEM_RESERVE: u32 = 0x0000_2000;
722 #[cfg(feature = "vmem-integration")]
723 const MEM_COMMIT: u32 = 0x0000_1000;
724 #[cfg(feature = "vmem-integration")]
725 const PAGE_READWRITE: u32 = 0x04;
726}
727
728// ---- macOS stub -----------------------------------------------------------
729#[cfg(target_os = "macos")]
730mod platform {
731 use super::NO_NODE;
732
733 /// macOS has no public NUMA API. Always returns `NO_NODE`.
734 pub(super) fn current_node_impl() -> u32 {
735 NO_NODE
736 }
737
738 /// No-op: macOS has no NUMA binding API.
739 pub(super) fn bind_range_impl(_base: *mut u8, _len: usize, _node: u32) {}
740
741 #[cfg(feature = "vmem-integration")]
742 pub(super) fn reserve_on_node_impl(
743 size: usize,
744 align: usize,
745 _node: u32,
746 ) -> Option<aligned_vmem::Reservation> {
747 // macOS: no NUMA API; plain reserve.
748 aligned_vmem::reserve_aligned(size, align)
749 }
750}
751
752// ---- miri stub (any OS under miri) ----------------------------------------
753#[cfg(miri)]
754mod platform {
755 use super::NO_NODE;
756
757 /// Under miri NUMA detection is not meaningful. Always returns `NO_NODE`.
758 pub(super) fn current_node_impl() -> u32 {
759 NO_NODE
760 }
761
762 /// No-op under miri.
763 pub(super) fn bind_range_impl(_base: *mut u8, _len: usize, _node: u32) {}
764
765 #[cfg(feature = "vmem-integration")]
766 pub(super) fn reserve_on_node_impl(
767 size: usize,
768 align: usize,
769 _node: u32,
770 ) -> Option<aligned_vmem::Reservation> {
771 aligned_vmem::reserve_aligned(size, align)
772 }
773}
774
775// ---- Fallback: unsupported platform (e.g. FreeBSD, other Unix) ------------
776#[cfg(not(any(target_os = "linux", windows, target_os = "macos", miri,)))]
777mod platform {
778 use super::NO_NODE;
779
780 /// Unsupported platform: always returns `NO_NODE`.
781 pub(super) fn current_node_impl() -> u32 {
782 NO_NODE
783 }
784
785 /// No-op on unsupported platforms.
786 pub(super) fn bind_range_impl(_base: *mut u8, _len: usize, _node: u32) {}
787
788 #[cfg(feature = "vmem-integration")]
789 pub(super) fn reserve_on_node_impl(
790 size: usize,
791 align: usize,
792 _node: u32,
793 ) -> Option<aligned_vmem::Reservation> {
794 aligned_vmem::reserve_aligned(size, align)
795 }
796}