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}