Skip to main content

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}