Skip to main content

ferrox_core/
threads.rs

1//! CPU worker-pool policy, shared by `ferrox` (CLI) and `ferrox-server`.
2//!
3//! Three things this module exists to control, all of which were measured
4//! to matter far more than any kernel change on Apple Silicon:
5//!
6//! 1. **Thread count.** `available_parallelism()` on an M2 Pro reports 10
7//!    (6 performance + 4 efficiency cores). Splitting a decode GEMV
8//!    across all 10 makes every fork-join wait on the slowest E-core
9//!    slice. llama.cpp defaults to `hw.perflevel0.physicalcpu` for
10//!    exactly this reason (`common_cpu_get_num_math`), and collapses when
11//!    forced above it -- 346 -> 176 tok/s on SmolLM2-135M Q8_0 going from
12//!    `-t 4` to `-t 10`. So default to the performance-core count, not
13//!    the logical-core count.
14//!
15//! 2. **Thread QoS.** macOS schedules threads onto E-cores based on their
16//!    Quality-of-Service class, and QoS is *inherited* from whichever
17//!    thread spawned them. Rayon builds its global pool lazily, on first
18//!    use -- which inside `ferrox-server` is a Tokio `spawn_blocking`
19//!    task, not the main thread. If that blocking thread carries a
20//!    demoted QoS, every rayon worker inherits it and the whole matvec
21//!    runs on efficiency cores. [`init_cpu_pool`] pins the workers to
22//!    `USER_INTERACTIVE` explicitly so the pool's placement does not
23//!    depend on who happened to touch rayon first.
24//!
25//! 3. **SMT siblings.** The same argument as (1), for the other kind of
26//!    fake core. On a 16C/32T host `available_parallelism()` reports 32,
27//!    so an auto-sized pool puts two workers on every physical core.
28//!    MoE decode is memory-bandwidth-bound: a sibling adds no bandwidth,
29//!    contends for the same core's load ports, and turns a spin barrier
30//!    into a livelock-grade tax once the pool is oversubscribed. So the
31//!    non-macOS width comes from [`physical_core_count`], which
32//!    deduplicates `thread_siblings_list` across this process's affinity
33//!    mask; sysfs being unreadable degrades to the
34//!    `available_parallelism` answer rather than failing.
35
36use std::collections::HashSet;
37use std::path::Path;
38
39/// macOS QoS classes (`sys/qos.h`). Only the ones we name are listed.
40#[cfg(target_os = "macos")]
41mod qos {
42    pub const QOS_CLASS_USER_INTERACTIVE: u32 = 0x21;
43    pub const QOS_CLASS_USER_INITIATED: u32 = 0x19;
44    pub const QOS_CLASS_DEFAULT: u32 = 0x15;
45    pub const QOS_CLASS_UTILITY: u32 = 0x11;
46    pub const QOS_CLASS_BACKGROUND: u32 = 0x09;
47
48    extern "C" {
49        pub fn pthread_set_qos_class_self_np(qos: u32, relative_priority: i32) -> i32;
50        pub fn qos_class_self() -> u32;
51    }
52
53    pub fn name(class: u32) -> &'static str {
54        match class {
55            QOS_CLASS_USER_INTERACTIVE => "user-interactive",
56            QOS_CLASS_USER_INITIATED => "user-initiated",
57            QOS_CLASS_DEFAULT => "default",
58            QOS_CLASS_UTILITY => "utility",
59            QOS_CLASS_BACKGROUND => "background",
60            _ => "unspecified",
61        }
62    }
63}
64
65/// Put the calling thread in the QoS class a decode worker needs.
66///
67/// The whole of point (2) in this module's header applies to *any* pool
68/// this crate builds, not just rayon's, so both the rayon `start_handler`
69/// in [`init_cpu_pool`] and [`crate::cpu_pool::CpuPool`]'s workers call
70/// this one function rather than each spelling the class out. Two pools
71/// that disagreed about their QoS would put one of them on E-cores and
72/// nothing would say so.
73///
74/// A no-op off macOS, where QoS does not exist.
75pub fn set_user_interactive_qos() {
76    #[cfg(target_os = "macos")]
77    {
78        let before = unsafe { qos::qos_class_self() };
79        // SAFETY: sets only the calling thread's QoS class.
80        let rc = unsafe { qos::pthread_set_qos_class_self_np(qos::QOS_CLASS_USER_INTERACTIVE, 0) };
81        if std::env::var_os("FERROX_QOS_LOG").is_some() {
82            eprintln!(
83                "ferrox: worker {:?} qos {} -> {} (rc={rc})",
84                std::thread::current().id(),
85                qos::name(before),
86                qos::name(unsafe { qos::qos_class_self() }),
87            );
88        }
89    }
90}
91
92/// The calling thread's macOS QoS class, as a human-readable name.
93/// `None` off macOS, where the concept does not exist.
94pub fn current_qos_name() -> Option<&'static str> {
95    #[cfg(target_os = "macos")]
96    {
97        Some(qos::name(unsafe { qos::qos_class_self() }))
98    }
99    #[cfg(not(target_os = "macos"))]
100    {
101        None
102    }
103}
104
105/// Number of *performance* cores, which is the useful width for a decode
106/// GEMV. On macOS this is `hw.perflevel0.physicalcpu` (llama.cpp reads
107/// the same key). Elsewhere it is [`physical_core_count`] — one logical
108/// CPU per physical core inside this process's affinity mask — and only
109/// a host whose sysfs topology is unreadable falls all the way back to
110/// `available_parallelism`.
111pub fn perf_core_count() -> usize {
112    #[cfg(target_os = "macos")]
113    {
114        if let Some(n) = sysctl_usize("hw.perflevel0.physicalcpu") {
115            if n > 0 {
116                return n;
117            }
118        }
119    }
120    physical_core_count()
121}
122
123#[cfg(target_os = "macos")]
124fn sysctl_usize(name: &str) -> Option<usize> {
125    use std::ffi::CString;
126    extern "C" {
127        fn sysctlbyname(
128            name: *const std::os::raw::c_char,
129            oldp: *mut std::ffi::c_void,
130            oldlenp: *mut usize,
131            newp: *mut std::ffi::c_void,
132            newlen: usize,
133        ) -> std::os::raw::c_int;
134    }
135    let key = CString::new(name).ok()?;
136    let mut out: i32 = 0;
137    let mut len = std::mem::size_of::<i32>();
138    // SAFETY: `key` is NUL-terminated, and `out`/`len` describe a live
139    // i32 of exactly `len` bytes, which is what these keys return.
140    let rc = unsafe {
141        sysctlbyname(
142            key.as_ptr(),
143            &mut out as *mut i32 as *mut std::ffi::c_void,
144            &mut len,
145            std::ptr::null_mut(),
146            0,
147        )
148    };
149    if rc == 0 && out > 0 {
150        Some(out as usize)
151    } else {
152        None
153    }
154}
155
156// ---------------------------------------------------------------------
157// SMT topology: one worker per physical core
158//
159// Ported from FreeToken's `freetoken/moe/cpu_executor.py`
160// (`physical_core_cpus`, `resolve_threads_and_affinity`, and the pool
161// sizing in `CpuMoeExecutor.__init__`). Apache-2.0; see
162// `docs/THIRD_PARTY_NOTICES.md`.
163//
164// Every rule below is a pure function over an injected [`CpuTopology`],
165// with a thin wrapper that reads the real `/sys`. That split is not
166// cosmetic: the rules only *matter* on an SMT host with a restricted
167// affinity mask, and no CI machine can be assumed to be one, so a
168// sysfs-reading implementation would be a set of policies that are never
169// actually exercised until a production box gets them wrong.
170// ---------------------------------------------------------------------
171
172/// Where Linux publishes per-CPU topology. [`CpuTopology::detect`] reads
173/// `{root}/cpu{n}/topology/thread_siblings_list` under this directory.
174pub const SYSFS_CPU_ROOT: &str = "/sys/devices/system/cpu";
175
176/// Parse one `thread_siblings_list` line into the logical CPU ids it
177/// names.
178///
179/// The kernel prints a cpulist, and both spellings occur on real
180/// hardware: `0-1` on a box that numbers a core's siblings adjacently,
181/// `0,64` on one that numbers every first-sibling before any second.
182/// Handling only one of the two silently collapses or explodes the
183/// deduplicated core count on the other, which is exactly the sizing
184/// mistake this module exists to prevent — so both are parsed, and mixed
185/// `0-1,64-65` forms with them. Tokens that are neither are dropped
186/// rather than poisoning the list with a bogus CPU id.
187pub fn parse_thread_siblings_list(text: &str) -> Vec<usize> {
188    let mut out = Vec::new();
189    for token in text.trim().split(',') {
190        let token = token.trim();
191        if token.is_empty() {
192            continue;
193        }
194        match token.split_once('-') {
195            Some((lo, hi)) => {
196                // A cpulist range may carry a `:used/group` stride
197                // suffix; sibling lists never do, so take the plain
198                // prefix and ignore anything past it.
199                let hi = hi.split(':').next().unwrap_or(hi);
200                if let (Ok(lo), Ok(hi)) = (lo.trim().parse::<usize>(), hi.trim().parse::<usize>()) {
201                    if lo <= hi {
202                        out.extend(lo..=hi);
203                    }
204                }
205            }
206            None => {
207                if let Ok(cpu) = token.parse::<usize>() {
208                    out.push(cpu);
209                }
210            }
211        }
212    }
213    out
214}
215
216/// The logical CPUs this process may actually run on, ascending.
217///
218/// This is the affinity mask, not the machine: a pool sized to the
219/// machine inside a `taskset`- or cpuset-confined container
220/// oversubscribes the slice it was given by exactly the factor it was
221/// confined by, and every worker then time-slices against every other.
222/// Linux reads `sched_getaffinity`; everywhere else (macOS included,
223/// which has no equivalent) this is `0..available_parallelism()`, i.e.
224/// today's answer.
225pub fn process_affinity_cpus() -> Vec<usize> {
226    #[cfg(target_os = "linux")]
227    {
228        if let Some(cpus) = sched_affinity_cpus() {
229            return cpus;
230        }
231    }
232    let n = std::thread::available_parallelism()
233        .map(|n| n.get())
234        .unwrap_or(1);
235    (0..n).collect()
236}
237
238#[cfg(target_os = "linux")]
239fn sched_affinity_cpus() -> Option<Vec<usize>> {
240    // SAFETY: a zeroed `cpu_set_t` is a valid (empty) mask; the kernel
241    // writes at most `cpusetsize` bytes into it, and we pass exactly the
242    // size of the live local. `CPU_ISSET` only reads that same local.
243    unsafe {
244        let mut set: libc::cpu_set_t = std::mem::zeroed();
245        let rc = libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut set);
246        if rc != 0 {
247            return None;
248        }
249        let cpus: Vec<usize> = (0..libc::CPU_SETSIZE as usize)
250            .filter(|&cpu| libc::CPU_ISSET(cpu, &set))
251            .collect();
252        if cpus.is_empty() {
253            None
254        } else {
255            Some(cpus)
256        }
257    }
258}
259
260/// The SMT layout of the logical CPUs this process may run on: for each
261/// allowed CPU, in ascending id order, the set of CPUs sharing its
262/// physical core.
263///
264/// An **empty** sibling list means "sysfs did not answer for this CPU".
265/// Such a CPU is treated as a physical core of its own and is never
266/// merged with another unknown one — guessing that two unreadable CPUs
267/// are siblings would silently halve the pool on any host that simply
268/// has no `topology/` directory (a container with a masked `/sys`, some
269/// hypervisors, anything non-Linux), and that regression arrives with no
270/// error attached to it.
271#[derive(Debug, Clone, PartialEq, Eq, Default)]
272pub struct CpuTopology {
273    /// `(cpu id, its thread siblings)`, ascending by cpu id, unique.
274    entries: Vec<(usize, Vec<usize>)>,
275}
276
277impl CpuTopology {
278    /// Build a topology from explicit sibling lists — the injection
279    /// point that makes every rule in this module testable on a host
280    /// with no SMT, no root, and no particular CPU count.
281    ///
282    /// Entries are sorted by CPU id and deduplicated, because the rules
283    /// pick the *first* allowed CPU of each physical core as that core's
284    /// representative and that choice must not depend on the order the
285    /// caller happened to enumerate in.
286    pub fn from_sibling_lists<I>(entries: I) -> Self
287    where
288        I: IntoIterator<Item = (usize, Vec<usize>)>,
289    {
290        let mut entries: Vec<(usize, Vec<usize>)> = entries.into_iter().collect();
291        entries.sort_by_key(|(cpu, _)| *cpu);
292        entries.dedup_by_key(|(cpu, _)| *cpu);
293        Self { entries }
294    }
295
296    /// Read `{root}/cpu{n}/topology/thread_siblings_list` for each CPU in
297    /// `allowed`. A CPU whose file is missing or unreadable gets an empty
298    /// sibling list (see the type docs: it becomes its own core), so a
299    /// host without sysfs topology degrades to one worker per allowed CPU
300    /// instead of erroring out mid-boot.
301    pub fn read_from(root: &Path, allowed: &[usize]) -> Self {
302        Self::from_sibling_lists(allowed.iter().map(|&cpu| {
303            let path = root
304                .join(format!("cpu{cpu}"))
305                .join("topology")
306                .join("thread_siblings_list");
307            let siblings = std::fs::read_to_string(&path)
308                .map(|text| parse_thread_siblings_list(&text))
309                .unwrap_or_default();
310            (cpu, siblings)
311        }))
312    }
313
314    /// This host's topology: [`SYSFS_CPU_ROOT`] restricted to
315    /// [`process_affinity_cpus`].
316    pub fn detect() -> Self {
317        Self::read_from(Path::new(SYSFS_CPU_ROOT), &process_affinity_cpus())
318    }
319
320    /// The allowed logical CPUs, ascending.
321    pub fn allowed_cpus(&self) -> Vec<usize> {
322        self.entries.iter().map(|(cpu, _)| *cpu).collect()
323    }
324
325    /// Number of allowed logical CPUs, SMT siblings included.
326    pub fn len(&self) -> usize {
327        self.entries.len()
328    }
329
330    /// Whether no CPU at all is described.
331    pub fn is_empty(&self) -> bool {
332        self.entries.is_empty()
333    }
334}
335
336/// One logical CPU per physical core, restricted to `topology`.
337///
338/// MoE decode is memory-bandwidth-bound, so SMT siblings only contend
339/// for the same core's load ports without adding bandwidth; one logical
340/// CPU per physical core is the fastest and, more importantly, the most
341/// *stable* width. Siblings are deduplicated by the set their
342/// `thread_siblings_list` names, and the lowest-numbered allowed CPU of
343/// each core wins.
344///
345/// Never returns an empty list: with no topology it degrades to the
346/// allowed CPUs, and with nothing allowed at all to `[0]`, because every
347/// caller downstream divides work by this length.
348pub fn physical_core_cpus_in(topology: &CpuTopology) -> Vec<usize> {
349    let mut reps: Vec<usize> = Vec::new();
350    let mut seen: HashSet<Vec<usize>> = HashSet::new();
351    for (cpu, siblings) in &topology.entries {
352        if siblings.is_empty() {
353            // Unknown topology for this CPU: a core of its own, never
354            // merged with another unknown CPU.
355            reps.push(*cpu);
356            continue;
357        }
358        let mut key = siblings.clone();
359        key.sort_unstable();
360        key.dedup();
361        if seen.insert(key) {
362            reps.push(*cpu);
363        }
364    }
365    if !reps.is_empty() {
366        return reps;
367    }
368    let allowed = topology.allowed_cpus();
369    if allowed.is_empty() {
370        vec![0]
371    } else {
372        allowed
373    }
374}
375
376/// [`physical_core_cpus_in`] against this host's real topology.
377pub fn physical_core_cpus() -> Vec<usize> {
378    physical_core_cpus_in(&CpuTopology::detect())
379}
380
381/// How many physical cores this process may run on.
382///
383/// Clamped by `available_parallelism`, which is the only figure that
384/// accounts for a cgroup CPU *quota* — a container pinned to 8 CPUs but
385/// throttled to two cores' worth of runtime reports 8 in its affinity
386/// mask and 2 here, and a pool built for 8 spends the difference being
387/// throttled mid-GEMV. A host with no readable topology lands on
388/// `available_parallelism` outright, which is the pre-topology answer.
389pub fn physical_core_count() -> usize {
390    let logical = std::thread::available_parallelism()
391        .map(|n| n.get())
392        .unwrap_or(1);
393    physical_core_cpus().len().clamp(1, logical.max(1))
394}
395
396/// `(num_threads, core_ids)` for a pinned worker pool.
397///
398/// `requested == 0` means one worker per physical core, pinned to it.
399/// An explicit count is honoured exactly, spreading across physical-core
400/// **representatives first** and only then across the remaining logical
401/// CPUs, so distinct hardware threads are used before any core is
402/// doubled up — filling CPUs in numeric order instead would put workers
403/// 0 and 1 on one core's two siblings on every host that numbers
404/// siblings adjacently, i.e. half the pool contending before a second
405/// core has been touched at all. A count larger than the allowed CPU set
406/// wraps, deliberately: the caller asked for that width.
407pub fn resolve_threads_and_affinity_in(
408    requested: usize,
409    topology: &CpuTopology,
410) -> (usize, Vec<usize>) {
411    let reps = physical_core_cpus_in(topology);
412    if requested == 0 {
413        let n = reps.len();
414        return (n, reps);
415    }
416    let rep_set: HashSet<usize> = reps.iter().copied().collect();
417    let mut order = reps;
418    order.extend(
419        topology
420            .allowed_cpus()
421            .into_iter()
422            .filter(|cpu| !rep_set.contains(cpu)),
423    );
424    if order.is_empty() {
425        order.push(0);
426    }
427    let core_ids = (0..requested).map(|i| order[i % order.len()]).collect();
428    (requested, core_ids)
429}
430
431/// [`resolve_threads_and_affinity_in`] against this host's real topology.
432pub fn resolve_threads_and_affinity(requested: usize) -> (usize, Vec<usize>) {
433    resolve_threads_and_affinity_in(requested, &CpuTopology::detect())
434}
435
436/// A sized worker pool: how many workers, which logical CPU each pins
437/// to, and the CPU set aside for a coordinator, if any.
438#[derive(Debug, Clone, PartialEq, Eq, Default)]
439pub struct CpuPoolPlan {
440    /// Worker count. Always equal to `core_ids.len()`.
441    pub num_threads: usize,
442    /// The logical CPU each worker pins to, in worker order.
443    pub core_ids: Vec<usize>,
444    /// The logical CPU donated to the coordinator, or `None` when no
445    /// coordinator was asked for or the pool was too small to donate.
446    pub coordinator_cpu: Option<usize>,
447}
448
449/// Size a pinned pool, optionally reserving a core for a coordinator
450/// thread (the one that polls the device doorbell and drives the pool).
451///
452/// The donation rule is the point: under *auto* sizing only, and only
453/// while more than two workers survive it, the coordinator takes the
454/// last physical core and the pool drops from N to N-1 workers. A
455/// coordinator that instead time-slices against a full-width pool
456/// measurably destabilizes throughput on a fully-subscribed box — it is
457/// a spinner, so the worker sharing its core is the one the fork-join
458/// barrier waits for, every single step. An explicit thread count is
459/// never silently reduced: the operator asked for that width.
460pub fn plan_cpu_pool_in(
461    requested: usize,
462    reserve_coordinator: bool,
463    topology: &CpuTopology,
464) -> CpuPoolPlan {
465    let (mut num_threads, mut core_ids) = resolve_threads_and_affinity_in(requested, topology);
466    let mut coordinator_cpu = None;
467    if reserve_coordinator && requested == 0 && num_threads > 2 {
468        coordinator_cpu = core_ids.pop();
469        num_threads -= 1;
470    }
471    CpuPoolPlan {
472        num_threads,
473        core_ids,
474        coordinator_cpu,
475    }
476}
477
478/// [`plan_cpu_pool_in`] against this host's real topology.
479pub fn plan_cpu_pool(requested: usize, reserve_coordinator: bool) -> CpuPoolPlan {
480    plan_cpu_pool_in(requested, reserve_coordinator, &CpuTopology::detect())
481}
482
483/// The intra-op width a *second* thread pool may still use once `plan`
484/// has claimed its cores: `physical_cores - workers - coordinator - 1`,
485/// clamped into `1..=configured`. The trailing `-1` is the calling
486/// thread itself, which is running the surrounding forward.
487///
488/// Without this clamp the framework's own pool defaults to the full core
489/// count and each of its threads lands on a core a pinned, spinning
490/// worker already owns — the pinned pool cannot yield, so the
491/// oversubscription is paid as scheduler latency on the decode critical
492/// path instead of buying parallelism anywhere. Never returns 0: a
493/// zero-width intra-op pool is not a degraded configuration, it is a
494/// broken one.
495pub fn clamp_intra_op_threads(
496    configured: usize,
497    plan: &CpuPoolPlan,
498    physical_cores: usize,
499) -> usize {
500    let coordinator = usize::from(plan.coordinator_cpu.is_some());
501    let spare = physical_cores
502        .saturating_sub(plan.num_threads)
503        .saturating_sub(coordinator)
504        .saturating_sub(1);
505    configured.min(spare).max(1)
506}
507
508/// How many rayon workers to run: `FERROX_CPU_THREADS`, else
509/// `RAYON_NUM_THREADS`, else [`perf_core_count`].
510pub fn resolve_cpu_threads() -> usize {
511    for key in ["FERROX_CPU_THREADS", "RAYON_NUM_THREADS"] {
512        if let Ok(v) = std::env::var(key) {
513            if let Ok(n) = v.trim().parse::<usize>() {
514                if n > 0 {
515                    return n;
516                }
517            }
518        }
519    }
520    perf_core_count()
521}
522
523/// Prefer serial when fork-join overhead exceeds the matvec work.
524/// ~256k element-ops matches the previous `prefer_serial_matvec` gate.
525pub fn should_parallelize(n_rows: usize, n_cols: usize) -> bool {
526    n_rows > 1 && n_rows.saturating_mul(n_cols) >= 256_000
527}
528
529/// One output row per slot; parallel when [`should_parallelize`] says the
530/// matvec is big enough to pay for the fork-join. Rows are never split.
531pub fn for_each_row<F>(output: &mut [f32], n_rows: usize, n_cols: usize, row_fn: F)
532where
533    F: Fn(usize, &mut f32) + Send + Sync,
534{
535    let n = n_rows.min(output.len());
536    if !should_parallelize(n, n_cols) {
537        for (row, out) in output.iter_mut().enumerate().take(n) {
538            row_fn(row, out);
539        }
540        return;
541    }
542    // Global rayon, the same pool act-quant uses. A second dedicated
543    // matvec pool of P-core width was measured to regress CPU pp512 on
544    // Host B (~40 -> ~14 tok/s), so there is only one pool.
545    let rows = &mut output[..n];
546    crate::par::items_mut(rows, 1, |row, out| row_fn(row, out));
547}
548
549/// Chunk-parallel sibling of [`for_each_row`]; chunks are never split.
550pub fn for_each_chunk_init<S, I, F>(
551    output: &mut [f32],
552    chunk_len: usize,
553    work_per_chunk: usize,
554    init: I,
555    f: F,
556) where
557    I: Fn() -> S + Send + Sync,
558    S: Send,
559    F: Fn(&mut S, usize, &mut [f32]) + Send + Sync,
560{
561    if chunk_len == 0 {
562        return;
563    }
564    let n_chunks = output.len() / chunk_len;
565    if !should_parallelize(n_chunks, work_per_chunk) {
566        let mut state = init();
567        for (i, chunk) in output[..n_chunks * chunk_len]
568            .chunks_mut(chunk_len)
569            .enumerate()
570        {
571            f(&mut state, i, chunk);
572        }
573        return;
574    }
575    let chunks = &mut output[..n_chunks * chunk_len];
576    crate::par::chunks_mut_init(chunks, chunk_len, 1, init, |state, i, c| f(state, i, c));
577}
578
579/// Builds the global rayon pool with an explicit width and an explicit
580/// QoS, so neither depends on which thread first touched rayon. Safe to
581/// call more than once and from either binary; a pool that already
582/// exists is left alone.
583///
584/// Returns the thread count the pool was built with, or `None` if the
585/// global pool already existed.
586pub fn init_cpu_pool() -> Option<usize> {
587    let threads = resolve_cpu_threads();
588    let built = rayon::ThreadPoolBuilder::new()
589        .num_threads(threads)
590        .start_handler(move |_idx| set_user_interactive_qos())
591        .build_global()
592        .is_ok();
593    if built {
594        Some(threads)
595    } else {
596        None
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn perf_core_count_is_at_least_one_and_no_more_than_logical_cores() {
606        let logical = std::thread::available_parallelism()
607            .map(|n| n.get())
608            .unwrap_or(1);
609        let perf = perf_core_count();
610        assert!(perf >= 1, "perf core count must be positive, got {perf}");
611        assert!(
612            perf <= logical,
613            "perf cores ({perf}) cannot exceed logical cores ({logical})"
614        );
615    }
616
617    #[test]
618    fn resolved_thread_count_falls_back_to_perf_cores_without_env_overrides() {
619        // `resolve_cpu_threads` reads process-global env, and tests share
620        // a process, so assert the fallback only when nothing is set.
621        if std::env::var_os("FERROX_CPU_THREADS").is_none()
622            && std::env::var_os("RAYON_NUM_THREADS").is_none()
623        {
624            assert_eq!(resolve_cpu_threads(), perf_core_count());
625        }
626    }
627
628    #[test]
629    fn current_qos_name_is_reported_on_macos_and_absent_elsewhere() {
630        let qos = current_qos_name();
631        #[cfg(target_os = "macos")]
632        assert!(qos.is_some(), "macOS must report a QoS class");
633        #[cfg(not(target_os = "macos"))]
634        assert!(qos.is_none(), "QoS is a macOS-only concept");
635    }
636
637    /// An 8-logical/4-physical SMT host, siblings numbered adjacently.
638    /// Every CPU is in the affinity mask.
639    fn smt_8t_4c() -> CpuTopology {
640        CpuTopology::from_sibling_lists([
641            (0, vec![0, 1]),
642            (1, vec![0, 1]),
643            (2, vec![2, 3]),
644            (3, vec![2, 3]),
645            (4, vec![4, 5]),
646            (5, vec![4, 5]),
647            (6, vec![6, 7]),
648            (7, vec![6, 7]),
649        ])
650    }
651
652    /// THE central test, and it FAILS against the pre-topology
653    /// implementation: on this host `available_parallelism()` reports 8
654    /// while there are only 4 physical cores, so the old
655    /// `perf_core_count` fallback would have sized the pool to 8 and put
656    /// two bandwidth-bound workers on every core. The auto width must be
657    /// the physical-core count, and the chosen CPUs must be one per core.
658    #[test]
659    fn an_smt_host_is_sized_to_its_physical_cores_not_its_logical_cpus() {
660        let topology = smt_8t_4c();
661        assert_eq!(
662            topology.len(),
663            8,
664            "the fixture must have twice as many logical CPUs as cores"
665        );
666        assert_eq!(
667            physical_core_cpus_in(&topology),
668            vec![0, 2, 4, 6],
669            "one representative per physical core, lowest sibling first"
670        );
671        let (threads, core_ids) = resolve_threads_and_affinity_in(0, &topology);
672        assert_eq!(threads, 4, "auto sizing must not count SMT siblings");
673        assert_eq!(core_ids, vec![0, 2, 4, 6]);
674    }
675
676    #[test]
677    fn siblings_numbered_apart_are_deduplicated_the_same_as_adjacent_ones() {
678        // AMD/POWER style: all first siblings, then all second siblings.
679        let topology = CpuTopology::from_sibling_lists([
680            (0, vec![0, 64]),
681            (1, vec![1, 65]),
682            (64, vec![0, 64]),
683            (65, vec![1, 65]),
684        ]);
685        assert_eq!(physical_core_cpus_in(&topology), vec![0, 1]);
686    }
687
688    #[test]
689    fn thread_siblings_lists_parse_as_ranges_comma_lists_and_mixtures() {
690        assert_eq!(parse_thread_siblings_list("0-1\n"), vec![0, 1]);
691        assert_eq!(parse_thread_siblings_list("0,64\n"), vec![0, 64]);
692        assert_eq!(parse_thread_siblings_list(" 3 "), vec![3]);
693        assert_eq!(parse_thread_siblings_list("0-1,64-65"), vec![0, 1, 64, 65]);
694        assert_eq!(parse_thread_siblings_list("2-4"), vec![2, 3, 4]);
695        // Garbage tokens are dropped, not turned into CPU 0.
696        assert_eq!(parse_thread_siblings_list(""), Vec::<usize>::new());
697        assert_eq!(parse_thread_siblings_list("x,-,7"), vec![7]);
698        // A descending range names nothing rather than panicking.
699        assert_eq!(parse_thread_siblings_list("5-1"), Vec::<usize>::new());
700    }
701
702    #[test]
703    fn a_host_without_sysfs_topology_degrades_to_one_worker_per_allowed_cpu() {
704        // Empty sibling lists model an unreadable `topology/` directory:
705        // the answer must be today's `available_parallelism`-shaped one.
706        let topology =
707            CpuTopology::from_sibling_lists((0..6).map(|cpu| (cpu, Vec::<usize>::new())));
708        assert_eq!(physical_core_cpus_in(&topology), vec![0, 1, 2, 3, 4, 5]);
709        assert_eq!(resolve_threads_and_affinity_in(0, &topology).0, 6);
710    }
711
712    #[test]
713    fn an_empty_topology_still_yields_one_usable_cpu() {
714        let topology = CpuTopology::default();
715        assert!(topology.is_empty());
716        assert_eq!(physical_core_cpus_in(&topology), vec![0]);
717        assert_eq!(resolve_threads_and_affinity_in(0, &topology), (1, vec![0]));
718        assert_eq!(
719            resolve_threads_and_affinity_in(2, &topology),
720            (2, vec![0, 0])
721        );
722    }
723
724    #[test]
725    fn cores_outside_the_affinity_mask_are_never_used_as_representatives() {
726        // Same 8T/4C host, but only CPUs 1, 3, 4, 5 are allowed. Cores
727        // {0,1} and {2,3} contribute their high sibling (the only allowed
728        // one); core {4,5} contributes 4 once, not twice.
729        let full = smt_8t_4c();
730        let allowed = [1usize, 3, 4, 5];
731        let topology = CpuTopology::from_sibling_lists(
732            full.allowed_cpus()
733                .into_iter()
734                .filter(|cpu| allowed.contains(cpu))
735                .map(|cpu| {
736                    (
737                        cpu,
738                        parse_thread_siblings_list(&format!("{}-{}", cpu & !1, cpu | 1)),
739                    )
740                }),
741        );
742        assert_eq!(physical_core_cpus_in(&topology), vec![1, 3, 4]);
743        assert_eq!(resolve_threads_and_affinity_in(0, &topology).0, 3);
744    }
745
746    #[test]
747    fn an_explicit_count_fills_physical_cores_before_doubling_up_siblings() {
748        let topology = smt_8t_4c();
749        // Four workers land on four distinct cores...
750        assert_eq!(
751            resolve_threads_and_affinity_in(4, &topology),
752            (4, vec![0, 2, 4, 6])
753        );
754        // ...and only the fifth onward touches a sibling.
755        assert_eq!(
756            resolve_threads_and_affinity_in(6, &topology),
757            (6, vec![0, 2, 4, 6, 1, 3])
758        );
759        assert_eq!(
760            resolve_threads_and_affinity_in(8, &topology),
761            (8, vec![0, 2, 4, 6, 1, 3, 5, 7])
762        );
763    }
764
765    #[test]
766    fn an_explicit_count_larger_than_the_machine_wraps_instead_of_truncating() {
767        let topology = smt_8t_4c();
768        let (threads, core_ids) = resolve_threads_and_affinity_in(10, &topology);
769        assert_eq!(threads, 10, "an explicit width is honoured exactly");
770        assert_eq!(core_ids.len(), 10);
771        assert_eq!(&core_ids[8..], &[0, 2], "wraps back to the representatives");
772    }
773
774    #[test]
775    fn auto_sizing_donates_the_last_physical_core_to_the_coordinator() {
776        let plan = plan_cpu_pool_in(0, true, &smt_8t_4c());
777        assert_eq!(plan.num_threads, 3, "workers drop from N to N-1");
778        assert_eq!(plan.core_ids, vec![0, 2, 4]);
779        assert_eq!(plan.coordinator_cpu, Some(6));
780        assert_eq!(plan.num_threads, plan.core_ids.len());
781    }
782
783    #[test]
784    fn no_core_is_donated_without_a_coordinator_or_for_an_explicit_count() {
785        let topology = smt_8t_4c();
786        let no_coordinator = plan_cpu_pool_in(0, false, &topology);
787        assert_eq!(no_coordinator.num_threads, 4);
788        assert_eq!(no_coordinator.coordinator_cpu, None);
789
790        // An operator-supplied width is never silently reduced.
791        let explicit = plan_cpu_pool_in(4, true, &topology);
792        assert_eq!(explicit.num_threads, 4);
793        assert_eq!(explicit.coordinator_cpu, None);
794    }
795
796    #[test]
797    fn a_pool_of_two_or_fewer_keeps_its_workers_rather_than_donating() {
798        // 2 physical cores: donating would leave a single worker, so the
799        // coordinator shares instead.
800        let dual = CpuTopology::from_sibling_lists([
801            (0, vec![0, 1]),
802            (1, vec![0, 1]),
803            (2, vec![2, 3]),
804            (3, vec![2, 3]),
805        ]);
806        let plan = plan_cpu_pool_in(0, true, &dual);
807        assert_eq!(plan.num_threads, 2);
808        assert_eq!(plan.coordinator_cpu, None);
809    }
810
811    #[test]
812    fn the_intra_op_clamp_leaves_a_core_for_the_calling_thread() {
813        // 16 physical cores, 3 workers + coordinator -> 16-3-1-1 = 11.
814        let plan = plan_cpu_pool_in(0, true, &smt_8t_4c());
815        assert_eq!(clamp_intra_op_threads(16, &plan, 16), 11);
816        // A configured width below the spare is left alone.
817        assert_eq!(clamp_intra_op_threads(4, &plan, 16), 4);
818        // A fully claimed machine still gets a usable intra-op width.
819        assert_eq!(clamp_intra_op_threads(16, &plan, 4), 1);
820        assert_eq!(clamp_intra_op_threads(16, &plan, 0), 1);
821    }
822
823    #[test]
824    fn sibling_lists_are_read_from_a_sysfs_layout_on_disk() {
825        let root = std::env::temp_dir().join(format!(
826            "ferrox-threads-sysfs-{}-{:?}",
827            std::process::id(),
828            std::thread::current().id()
829        ));
830        let _ = std::fs::remove_dir_all(&root);
831        // cpu0/cpu1 are siblings; cpu2 has no topology directory at all.
832        for cpu in [0usize, 1] {
833            let dir = root.join(format!("cpu{cpu}")).join("topology");
834            std::fs::create_dir_all(&dir).expect("temp sysfs tree must be creatable");
835            std::fs::write(dir.join("thread_siblings_list"), "0-1\n")
836                .expect("temp sibling list must be writable");
837        }
838        let topology = CpuTopology::read_from(&root, &[0, 1, 2]);
839        assert_eq!(topology.allowed_cpus(), vec![0, 1, 2]);
840        assert_eq!(
841            physical_core_cpus_in(&topology),
842            vec![0, 2],
843            "cpu2 is unreadable, so it counts as a core of its own"
844        );
845        let _ = std::fs::remove_dir_all(&root);
846    }
847
848    #[test]
849    fn this_hosts_physical_core_count_is_positive_and_within_its_logical_cpus() {
850        let logical = std::thread::available_parallelism()
851            .map(|n| n.get())
852            .unwrap_or(1);
853        let physical = physical_core_count();
854        assert!(physical >= 1, "physical core count must be positive");
855        assert!(
856            physical <= logical,
857            "physical cores ({physical}) cannot exceed logical cores ({logical})"
858        );
859        assert_eq!(
860            physical_core_cpus().len().clamp(1, logical.max(1)),
861            physical
862        );
863    }
864
865    #[test]
866    fn this_hosts_affinity_mask_is_non_empty_and_ascending() {
867        let cpus = process_affinity_cpus();
868        assert!(!cpus.is_empty(), "a running process may run somewhere");
869        assert!(
870            cpus.windows(2).all(|w| w[0] < w[1]),
871            "affinity CPUs must be ascending and unique: {cpus:?}"
872        );
873    }
874
875    #[test]
876    fn for_each_row_parallel_matches_serial() {
877        let n = 4097usize;
878        let f = |row: usize| ((row % 97) as f32) * 0.25 - 3.0;
879
880        let mut par = vec![0.0f32; n];
881        for_each_row(&mut par, n, 4096, |row, slot| *slot = f(row));
882
883        let mut serial = vec![0.0f32; n];
884        for (row, slot) in serial.iter_mut().enumerate() {
885            *slot = f(row);
886        }
887        assert_eq!(par, serial);
888    }
889}