Skip to main content

ferrox_core/
threads.rs

1//! CPU worker-pool policy, shared by `ferrox` (CLI) and `ferrox-server`.
2//!
3//! Two things this module exists to control, both 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. **Dedicated GEMV pool.** Row-parallel matvec runs on a
26//!    crate-owned [`rayon::ThreadPool`], not rayon's global pool, so
27//!    library consumers and Tokio blocking threads do not fight over
28//!    thread count or scheduling. See [`for_each_row`].
29
30use rayon::prelude::*;
31use std::sync::OnceLock;
32
33/// macOS QoS classes (`sys/qos.h`). Only the ones we name are listed.
34#[cfg(target_os = "macos")]
35mod qos {
36    pub const QOS_CLASS_USER_INTERACTIVE: u32 = 0x21;
37    pub const QOS_CLASS_USER_INITIATED: u32 = 0x19;
38    pub const QOS_CLASS_DEFAULT: u32 = 0x15;
39    pub const QOS_CLASS_UTILITY: u32 = 0x11;
40    pub const QOS_CLASS_BACKGROUND: u32 = 0x09;
41
42    extern "C" {
43        pub fn pthread_set_qos_class_self_np(qos: u32, relative_priority: i32) -> i32;
44        pub fn qos_class_self() -> u32;
45    }
46
47    pub fn name(class: u32) -> &'static str {
48        match class {
49            QOS_CLASS_USER_INTERACTIVE => "user-interactive",
50            QOS_CLASS_USER_INITIATED => "user-initiated",
51            QOS_CLASS_DEFAULT => "default",
52            QOS_CLASS_UTILITY => "utility",
53            QOS_CLASS_BACKGROUND => "background",
54            _ => "unspecified",
55        }
56    }
57}
58
59/// The calling thread's macOS QoS class, as a human-readable name.
60/// `None` off macOS, where the concept does not exist.
61pub fn current_qos_name() -> Option<&'static str> {
62    #[cfg(target_os = "macos")]
63    {
64        Some(qos::name(unsafe { qos::qos_class_self() }))
65    }
66    #[cfg(not(target_os = "macos"))]
67    {
68        None
69    }
70}
71
72/// Number of *performance* cores, which is the useful width for a decode
73/// GEMV. On macOS this is `hw.perflevel0.physicalcpu` (llama.cpp reads
74/// the same key). Elsewhere, and if the query fails, falls back to
75/// `available_parallelism`.
76pub fn perf_core_count() -> usize {
77    #[cfg(target_os = "macos")]
78    {
79        if let Some(n) = sysctl_usize("hw.perflevel0.physicalcpu") {
80            if n > 0 {
81                return n;
82            }
83        }
84    }
85    std::thread::available_parallelism()
86        .map(|n| n.get())
87        .unwrap_or(1)
88}
89
90#[cfg(target_os = "macos")]
91fn sysctl_usize(name: &str) -> Option<usize> {
92    use std::ffi::CString;
93    extern "C" {
94        fn sysctlbyname(
95            name: *const std::os::raw::c_char,
96            oldp: *mut std::ffi::c_void,
97            oldlenp: *mut usize,
98            newp: *mut std::ffi::c_void,
99            newlen: usize,
100        ) -> std::os::raw::c_int;
101    }
102    let key = CString::new(name).ok()?;
103    let mut out: i32 = 0;
104    let mut len = std::mem::size_of::<i32>();
105    // SAFETY: `key` is NUL-terminated, and `out`/`len` describe a live
106    // i32 of exactly `len` bytes, which is what these keys return.
107    let rc = unsafe {
108        sysctlbyname(
109            key.as_ptr(),
110            &mut out as *mut i32 as *mut std::ffi::c_void,
111            &mut len,
112            std::ptr::null_mut(),
113            0,
114        )
115    };
116    if rc == 0 && out > 0 {
117        Some(out as usize)
118    } else {
119        None
120    }
121}
122
123/// How many rayon workers to run: `FERROX_CPU_THREADS`, else
124/// `RAYON_NUM_THREADS`, else [`perf_core_count`].
125pub fn resolve_cpu_threads() -> usize {
126    for key in ["FERROX_CPU_THREADS", "RAYON_NUM_THREADS"] {
127        if let Ok(v) = std::env::var(key) {
128            if let Ok(n) = v.trim().parse::<usize>() {
129                if n > 0 {
130                    return n;
131                }
132            }
133        }
134    }
135    perf_core_count()
136}
137
138/// GEMV pool width: `FERROX_GEMV_THREADS`, else [`resolve_cpu_threads`].
139pub fn resolve_gemv_threads() -> usize {
140    if let Ok(v) = std::env::var("FERROX_GEMV_THREADS") {
141        if let Ok(n) = v.trim().parse::<usize>() {
142            if n > 0 {
143                return n;
144            }
145        }
146    }
147    resolve_cpu_threads()
148}
149
150static GEMV_POOL: OnceLock<Option<rayon::ThreadPool>> = OnceLock::new();
151
152fn gemv_pool_qos_start_handler(idx: usize) {
153    #[cfg(target_os = "macos")]
154    {
155        let log = std::env::var_os("FERROX_QOS_LOG").is_some();
156        let before = unsafe { qos::qos_class_self() };
157        let rc = unsafe { qos::pthread_set_qos_class_self_np(qos::QOS_CLASS_USER_INTERACTIVE, 0) };
158        if log {
159            eprintln!(
160                "ferrox: gemv worker {idx} qos {} -> {} (rc={rc})",
161                qos::name(before),
162                qos::name(unsafe { qos::qos_class_self() }),
163            );
164        }
165    }
166    #[cfg(not(target_os = "macos"))]
167    {
168        let _ = idx;
169    }
170}
171
172fn gemv_pool() -> Option<&'static rayon::ThreadPool> {
173    GEMV_POOL
174        .get_or_init(|| {
175            rayon::ThreadPoolBuilder::new()
176                .num_threads(resolve_gemv_threads())
177                .thread_name(|i| format!("ferrox-gemv-{i}"))
178                .start_handler(gemv_pool_qos_start_handler)
179                .build()
180                .ok()
181        })
182        .as_ref()
183}
184
185/// Eagerly build the dedicated GEMV pool (no-op if already built).
186pub fn init_gemv_pool() {
187    let _ = gemv_pool();
188}
189
190/// Active GEMV pool width, or `1` when the pool is unavailable.
191pub fn gemv_num_threads() -> usize {
192    match gemv_pool() {
193        Some(pool) => pool.current_num_threads(),
194        None => 1,
195    }
196}
197
198/// Prefer serial when fork-join overhead exceeds the matvec work.
199/// ~256k element-ops matches the previous `prefer_serial_matvec` gate.
200pub fn should_parallelize(n_rows: usize, n_cols: usize) -> bool {
201    n_rows > 1 && n_rows.saturating_mul(n_cols) >= 256_000
202}
203
204/// One output row per slot; parallel when [`should_parallelize`] and the
205/// GEMV pool has more than one worker. Rows are never split.
206pub fn for_each_row<F>(output: &mut [f32], n_rows: usize, n_cols: usize, row_fn: F)
207where
208    F: Fn(usize, &mut f32) + Send + Sync,
209{
210    let n = n_rows.min(output.len());
211    if !should_parallelize(n, n_cols) {
212        for (row, out) in output.iter_mut().enumerate().take(n) {
213            row_fn(row, out);
214        }
215        return;
216    }
217    // Default: global rayon (same pool as act-quant). Dedicated pool via
218    // FERROX_GEMV_DEDICATED=1 once callers stop using global rayon mid-matvec.
219    let use_dedicated = matches!(
220        std::env::var("FERROX_GEMV_DEDICATED").ok().as_deref(),
221        Some("1") | Some("true") | Some("on")
222    );
223    let rows = &mut output[..n];
224    if use_dedicated {
225        if let Some(pool) = gemv_pool() {
226            if pool.current_num_threads() > 1 {
227                pool.install(move || {
228                    rows.par_iter_mut()
229                        .enumerate()
230                        .for_each(|(row, out)| row_fn(row, out));
231                });
232                return;
233            }
234        }
235    }
236    rows.par_iter_mut()
237        .enumerate()
238        .for_each(|(row, out)| row_fn(row, out));
239}
240
241/// Chunk-parallel sibling of [`for_each_row`]; chunks are never split.
242pub fn for_each_chunk_init<S, I, F>(
243    output: &mut [f32],
244    chunk_len: usize,
245    work_per_chunk: usize,
246    init: I,
247    f: F,
248) where
249    I: Fn() -> S + Send + Sync,
250    S: Send,
251    F: Fn(&mut S, usize, &mut [f32]) + Send + Sync,
252{
253    if chunk_len == 0 {
254        return;
255    }
256    let n_chunks = output.len() / chunk_len;
257    if !should_parallelize(n_chunks, work_per_chunk) {
258        let mut state = init();
259        for (i, chunk) in output[..n_chunks * chunk_len]
260            .chunks_mut(chunk_len)
261            .enumerate()
262        {
263            f(&mut state, i, chunk);
264        }
265        return;
266    }
267    let use_dedicated = matches!(
268        std::env::var("FERROX_GEMV_DEDICATED").ok().as_deref(),
269        Some("1") | Some("true") | Some("on")
270    );
271    let chunks = &mut output[..n_chunks * chunk_len];
272    let init = &init;
273    let f = &f;
274    if use_dedicated {
275        if let Some(pool) = gemv_pool() {
276            if pool.current_num_threads() > 1 {
277                pool.install(move || {
278                    chunks
279                        .par_chunks_mut(chunk_len)
280                        .enumerate()
281                        .for_each_init(init, |state, (i, c)| f(state, i, c));
282                });
283                return;
284            }
285        }
286    }
287    chunks
288        .par_chunks_mut(chunk_len)
289        .enumerate()
290        .for_each_init(init, |state, (i, c)| f(state, i, c));
291}
292
293/// Builds the global rayon pool with an explicit width and an explicit
294/// QoS, so neither depends on which thread first touched rayon. Safe to
295/// call more than once and from either binary; a pool that already
296/// exists is left alone.
297///
298/// Returns the thread count the pool was built with, or `None` if the
299/// global pool already existed.
300/// Builds the dedicated GEMV pool (and, for legacy callers that still
301/// touch `rayon::prelude` on the global pool, a matching global pool).
302/// Prefer [`for_each_row`] / [`for_each_chunk_init`] so matvecs stay on
303/// the dedicated pool — mixing both pools oversubscribes P-cores.
304///
305/// Returns the thread count the **global** pool was built with, or
306/// `None` if the global pool already existed.
307pub fn init_cpu_pool() -> Option<usize> {
308    // Do not eagerly build the dedicated GEMV pool here — a second idle
309    // rayon pool of P-core width was measured to regress CPU pp512 on
310    // Host B (~40 → ~14 tok/s). Callers opt in via `for_each_*` (lazy) or
311    // `init_gemv_pool()` + `FERROX_GEMV_DEDICATED=1`.
312    let threads = resolve_cpu_threads();
313    let log = std::env::var_os("FERROX_QOS_LOG").is_some();
314    let built = rayon::ThreadPoolBuilder::new()
315        .num_threads(threads)
316        .start_handler(move |idx| {
317            #[cfg(target_os = "macos")]
318            {
319                let before = unsafe { qos::qos_class_self() };
320                // SAFETY: sets only the calling thread's QoS class.
321                let rc = unsafe {
322                    qos::pthread_set_qos_class_self_np(qos::QOS_CLASS_USER_INTERACTIVE, 0)
323                };
324                if log {
325                    eprintln!(
326                        "ferrox: rayon worker {idx} qos {} -> {} (rc={rc})",
327                        qos::name(before),
328                        qos::name(unsafe { qos::qos_class_self() }),
329                    );
330                }
331            }
332            #[cfg(not(target_os = "macos"))]
333            {
334                let _ = (idx, log);
335            }
336        })
337        .build_global()
338        .is_ok();
339    if built {
340        Some(threads)
341    } else {
342        None
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn perf_core_count_is_at_least_one_and_no_more_than_logical_cores() {
352        let logical = std::thread::available_parallelism()
353            .map(|n| n.get())
354            .unwrap_or(1);
355        let perf = perf_core_count();
356        assert!(perf >= 1, "perf core count must be positive, got {perf}");
357        assert!(
358            perf <= logical,
359            "perf cores ({perf}) cannot exceed logical cores ({logical})"
360        );
361    }
362
363    #[test]
364    fn resolved_thread_count_falls_back_to_perf_cores_without_env_overrides() {
365        // `resolve_cpu_threads` reads process-global env, and tests share
366        // a process, so assert the fallback only when nothing is set.
367        if std::env::var_os("FERROX_CPU_THREADS").is_none()
368            && std::env::var_os("RAYON_NUM_THREADS").is_none()
369        {
370            assert_eq!(resolve_cpu_threads(), perf_core_count());
371        }
372    }
373
374    #[test]
375    fn current_qos_name_is_reported_on_macos_and_absent_elsewhere() {
376        let qos = current_qos_name();
377        #[cfg(target_os = "macos")]
378        assert!(qos.is_some(), "macOS must report a QoS class");
379        #[cfg(not(target_os = "macos"))]
380        assert!(qos.is_none(), "QoS is a macOS-only concept");
381    }
382
383    #[test]
384    fn for_each_row_parallel_matches_serial() {
385        let n = 4097usize;
386        let f = |row: usize| ((row % 97) as f32) * 0.25 - 3.0;
387
388        let mut par = vec![0.0f32; n];
389        for_each_row(&mut par, n, 4096, |row, slot| *slot = f(row));
390
391        let mut serial = vec![0.0f32; n];
392        for (row, slot) in serial.iter_mut().enumerate() {
393            *slot = f(row);
394        }
395        assert_eq!(par, serial);
396    }
397}