Skip to content

Commit f711c5c

Browse files
committed
system-info: detect thread/L1 sizes at runtime, drop build.rs
1 parent c9f533f commit f711c5c

7 files changed

Lines changed: 86 additions & 101 deletions

File tree

crates/backend/parallel/src/lib.rs

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2323
use std::sync::{Mutex, Once, OnceLock};
2424
use std::thread::Thread;
2525

26-
use system_info::NUM_THREADS;
27-
2826
/// Idle spins before a worker parks: long enough to stay hot across back-to-back dispatches,
2927
/// short enough to yield the core during sequential gaps.
3028
const SPIN_LIMIT: u32 = 1 << 12;
@@ -33,18 +31,18 @@ const SPIN_LIMIT: u32 = 1 << 12;
3331
/// million-task kernels to a few thousand claims.
3432
const MAX_CLAIM_BATCH: usize = 1 << 12;
3533

36-
/// Worker count including the dispatcher (= build-time `NUM_THREADS`).
34+
/// Worker count including the dispatcher. Resolved once at runtime (see [`system_info::num_threads`]).
3735
#[must_use]
38-
pub const fn num_threads() -> usize {
39-
NUM_THREADS
36+
pub fn num_threads() -> usize {
37+
system_info::num_threads()
4038
}
4139

4240
/// Chunk size for a flat fan-out: a few chunks per worker — fine enough for the counter to
4341
/// rebalance heterogeneous cores, coarse enough to amortize dispatch.
4442
#[must_use]
4543
#[inline]
4644
pub fn recommended_chunk_size(n_items: usize) -> usize {
47-
n_items.div_ceil(NUM_THREADS * 4).max(1)
45+
n_items.div_ceil(num_threads() * 4).max(1)
4846
}
4947

5048
thread_local! {
@@ -107,28 +105,20 @@ unsafe impl Send for Pool {}
107105

108106
/// Idempotent warm-up: spawn workers and run one empty dispatch so the pool and the (macOS)
109107
/// lazily-allocated mutex exist before timed work; otherwise the pool inits on first use.
110-
///
111-
/// Also fail-fast if the machine's core count differs from the build-time [`NUM_THREADS`] (which
112-
/// sizes the pool): a mismatch silently over/under-subscribes every kernel.
113108
pub fn init() {
114109
static INIT: Once = Once::new();
115110
INIT.call_once(|| {
116-
let actual = std::thread::available_parallelism().unwrap().get();
117-
assert_eq!(
118-
actual, NUM_THREADS,
119-
"parallel pool built for {NUM_THREADS} threads but this machine reports {actual} -> please rebuild with env variable: LEANVM_NUM_THREADS={actual}"
120-
);
121111
let _ = pool();
122-
if NUM_THREADS > 1 {
123-
for_each_index(NUM_THREADS, |_| {});
112+
if num_threads() > 1 {
113+
for_each_index(num_threads(), |_| {});
124114
}
125115
});
126116
}
127117

128118
fn pool() -> &'static Pool {
129119
static POOL: OnceLock<&'static Pool> = OnceLock::new();
130120
POOL.get_or_init(|| {
131-
let n = NUM_THREADS.max(1);
121+
let n = num_threads().max(1);
132122
let p: &'static Pool = Box::leak(Box::new(Pool {
133123
job: UnsafeCell::new(None),
134124
generation: AtomicUsize::new(0),
@@ -200,6 +190,7 @@ fn drain(pool: &Pool) {
200190
// SAFETY: `job.f` borrows a `&dyn Fn` the blocked dispatcher keeps live.
201191
let f = unsafe { job.f.as_ref() };
202192
let n = job.n_tasks;
193+
let nt = num_threads();
203194
let prev = IN_TASK.replace(true); // catch nested dispatch (see `for_each_chunk`)
204195
// Catch a task panic so it can't unwind across `worker_main` (skipping the `working`
205196
// decrement → deadlock) or poison the dispatch lock; `for_each_chunk` re-raises it.
@@ -210,7 +201,7 @@ fn drain(pool: &Pool) {
210201
if observed >= n {
211202
break;
212203
}
213-
let batch = ((n - observed) / (NUM_THREADS * 2)).clamp(1, MAX_CLAIM_BATCH);
204+
let batch = ((n - observed) / (nt * 2)).clamp(1, MAX_CLAIM_BATCH);
214205
let start = pool.counter.fetch_add(batch, Ordering::Relaxed);
215206
if start >= n {
216207
break;
@@ -232,7 +223,8 @@ pub fn for_each_chunk<F: Fn(usize, usize) + Sync>(n_tasks: usize, f: F) {
232223
assert!(!IN_TASK.get(), "nested parallel dispatch from within a pool task");
233224

234225
// Trivial sizes / single-core builds run inline.
235-
if NUM_THREADS <= 1 || n_tasks <= 1 {
226+
let nt = num_threads();
227+
if nt <= 1 || n_tasks <= 1 {
236228
if n_tasks > 0 {
237229
f(0, n_tasks);
238230
}
@@ -252,7 +244,7 @@ pub fn for_each_chunk<F: Fn(usize, usize) + Sync>(n_tasks: usize, f: F) {
252244
// SAFETY: sole writer — prior dispatch fully drained (`working == 0`), next not yet observed.
253245
unsafe { *pool.job.get() = Some(Job { f: f_erased, n_tasks }) };
254246
pool.counter.store(0, Ordering::Relaxed);
255-
pool.working.store(NUM_THREADS - 1, Ordering::Release);
247+
pool.working.store(nt - 1, Ordering::Release);
256248
pool.generation.fetch_add(1, Ordering::SeqCst); // publish; SeqCst guards the park protocol
257249

258250
// Wake only parked workers; spinning ones see the bump for free.
@@ -389,7 +381,7 @@ pub fn par_fill<T: Send, F: Fn(usize) -> T + Sync>(dst: &mut [T], build: F) {
389381
/// `run(slot, start, end)` fires once per claimed batch with that worker's slot, so state
390382
/// accumulates across its batches. Returns the slots (rest `None`) for the caller to combine.
391383
fn drain_into_slots<S: Send>(n_tasks: usize, run: impl Fn(&mut Option<S>, usize, usize) + Sync) -> Vec<Option<S>> {
392-
let mut slots: Vec<Option<S>> = (0..NUM_THREADS).map(|_| None).collect();
384+
let mut slots: Vec<Option<S>> = (0..num_threads()).map(|_| None).collect();
393385
let ptr = SendPtr(slots.as_mut_ptr());
394386
for_each_chunk(n_tasks, |start, end| {
395387
// SAFETY: `current_worker_id() < NUM_THREADS` is unique per live worker → disjoint

crates/backend/poly/src/eq_mle.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,20 @@ use crate::*;
22
use crate::{EFPacking, PF};
33
use ::utils::{iter_array_chunks_padded, log2_ceil_usize, log2_strict_usize};
44
use field::*;
5-
use system_info::NUM_THREADS;
5+
use system_info::num_threads;
66
use zk_alloc::ArenaVec;
77

8-
const LOG_NUM_THREADS: usize = log2_ceil_usize(NUM_THREADS);
98
const LOG_BATCHED_TILE_SIZE: usize = 14;
109

11-
/// log2 oversubscription for the eq_mle fan-out: emit `NUM_THREADS << this` chunks so the
10+
/// log2 oversubscription for the eq_mle fan-out: emit `num_threads() << this` chunks so the
1211
/// pool's task counter rebalances across heterogeneous cores (e.g. P/E). `0` = one chunk
1312
/// per worker; `2` (4x) is a conservative default that balances well without over-fragmenting.
1413
const PARALLEL_LOG_OVERSUB: usize = 2;
1514

1615
/// `(log2(n_chunks), n_chunks)` for the parallel fan-out.
1716
#[inline]
1817
fn parallel_split() -> (usize, usize) {
19-
let log_chunks = LOG_NUM_THREADS + PARALLEL_LOG_OVERSUB;
18+
let log_chunks = log2_ceil_usize(num_threads()) + PARALLEL_LOG_OVERSUB;
2019
(log_chunks, 1 << log_chunks)
2120
}
2221

crates/backend/system-info/build.rs

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,61 @@
1-
include!(concat!(env!("OUT_DIR"), "/info.rs"));
1+
use std::sync::OnceLock;
22

33
const _: () = assert!(usize::BITS == 64, "this project requires a 64-bit target (for now)");
44

5+
#[must_use]
6+
pub fn num_threads() -> usize {
7+
static CACHE: OnceLock<usize> = OnceLock::new();
8+
*CACHE.get_or_init(|| {
9+
std::thread::available_parallelism()
10+
.expect("failed to detect available parallelism")
11+
.get()
12+
})
13+
}
14+
15+
#[must_use]
16+
pub fn l1_cache_size() -> usize {
17+
static CACHE: OnceLock<usize> = OnceLock::new();
18+
*CACHE.get_or_init(|| {
19+
detect_l1_cache_size().unwrap_or_else(|| {
20+
eprintln!("Warning: failed to detect L1 cache size, defaulting to 32 KB");
21+
32 * 1024
22+
})
23+
})
24+
}
25+
526
pub fn peak_rss_bytes() -> u64 {
627
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
728
unsafe { libc::getrusage(libc::RUSAGE_SELF, &raw mut ru) };
829
let max = ru.ru_maxrss as u64;
930
// ru_maxrss unit: bytes on macOS, KiB on Linux.
1031
if cfg!(target_os = "macos") { max } else { max * 1024 }
1132
}
33+
34+
#[cfg(target_os = "linux")]
35+
fn detect_l1_cache_size() -> Option<usize> {
36+
// /sys reports e.g. "32K\n", "48K\n", "1M\n".
37+
let s = std::fs::read_to_string("/sys/devices/system/cpu/cpu0/cache/index0/size").ok()?;
38+
let s = s.trim();
39+
let last = s.chars().last()?;
40+
match last {
41+
'K' | 'k' => s[..s.len() - 1].parse::<usize>().ok().map(|n| n * 1024),
42+
'M' | 'm' => s[..s.len() - 1].parse::<usize>().ok().map(|n| n * 1024 * 1024),
43+
c if c.is_ascii_digit() => s.parse().ok(),
44+
_ => None,
45+
}
46+
}
47+
48+
#[cfg(target_os = "macos")]
49+
fn detect_l1_cache_size() -> Option<usize> {
50+
// `hw.l1dcachesize` returns the E-core value on Apple Silicon; prefer the P-core size.
51+
let read_sysctl = |key: &str| -> Option<usize> {
52+
let out = std::process::Command::new("sysctl").args(["-n", key]).output().ok()?;
53+
std::str::from_utf8(&out.stdout).ok()?.trim().parse().ok()
54+
};
55+
read_sysctl("hw.perflevel0.l1dcachesize").or_else(|| read_sysctl("hw.l1dcachesize"))
56+
}
57+
58+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
59+
fn detect_l1_cache_size() -> Option<usize> {
60+
None
61+
}

crates/backend/zk-alloc/src/lib.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::cell::Cell;
88
use std::sync::OnceLock;
99
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1010

11-
use system_info::NUM_THREADS;
11+
use system_info::num_threads;
1212

1313
mod arena_cow;
1414
mod arena_vec;
@@ -27,8 +27,15 @@ macro_rules! arena_vec {
2727

2828
const SLAB_SIZE: usize = 8 << 30; // 8 GiB; per-thread soft cap, overflow falls back to System
2929
const SLACK: usize = 4; // extra slabs for non-pool threads that allocate in a phase
30-
const MAX_THREADS: usize = NUM_THREADS + SLACK;
31-
const REGION_SIZE: usize = SLAB_SIZE * MAX_THREADS; // one contiguous region => O(1) pointer classification
30+
31+
fn max_threads() -> usize {
32+
num_threads() + SLACK
33+
}
34+
35+
fn region_size() -> usize {
36+
static SIZE: OnceLock<usize> = OnceLock::new();
37+
*SIZE.get_or_init(|| SLAB_SIZE * max_threads())
38+
}
3239

3340
/// Bumped by `begin_phase()`; a thread resets its slab when its cached `ARENA_GEN` lags — one store
3441
/// resets every thread, lock-free.
@@ -58,12 +65,13 @@ thread_local! {
5865

5966
fn ensure_region() -> usize {
6067
*REGION.get_or_init(|| {
68+
let size = region_size();
6169
// SAFETY: mmap returns a page-aligned pointer or null; lazily backed.
62-
let ptr = unsafe { syscall::mmap_anonymous(REGION_SIZE) };
70+
let ptr = unsafe { syscall::mmap_anonymous(size) };
6371
if ptr.is_null() {
6472
std::process::abort();
6573
}
66-
unsafe { syscall::madvise(ptr, REGION_SIZE, syscall::MADV_NOHUGEPAGE) };
74+
unsafe { syscall::madvise(ptr, size, syscall::MADV_NOHUGEPAGE) };
6775
ptr as usize
6876
})
6977
}
@@ -125,7 +133,7 @@ unsafe fn arena_alloc_cold(size: usize, align: usize) -> *mut u8 {
125133
if base == 0 {
126134
let region = ensure_region();
127135
let idx = THREAD_IDX.fetch_add(1, Ordering::Relaxed);
128-
if idx >= MAX_THREADS {
136+
if idx >= max_threads() {
129137
ARENA_NO_SLAB.set(true);
130138
return unsafe { std::alloc::System.alloc(Layout::from_size_align_unchecked(size, align)) };
131139
}
@@ -177,7 +185,7 @@ pub(crate) unsafe fn raw_dealloc(ptr: *mut u8, size: usize, align: usize) {
177185
let addr = ptr as usize;
178186
if REGION
179187
.get()
180-
.is_some_and(|&base| addr >= base && addr < base + REGION_SIZE)
188+
.is_some_and(|&base| addr >= base && addr < base + region_size())
181189
{
182190
return; // arena pointer — free is a no-op
183191
}

crates/whir/src/dft.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,8 @@ fn fft_triple_layer_quad_twiddle<F: Field, Fly: Butterfly<F>>(
419419

420420
/// Estimates the optimal workload size for `T` to fit in L1 cache.
421421
#[must_use]
422-
const fn workload_size<T: Sized>() -> usize {
423-
system_info::L1_CACHE_SIZE / size_of::<T>()
422+
fn workload_size<T: Sized>() -> usize {
423+
system_info::l1_cache_size() / size_of::<T>()
424424
}
425425

426426
/// Estimates the optimal number of rows of a `RowMajorMatrix<T>` to take in each parallel chunk.

crates/whir/src/utils.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ fn prepare_evals_for_fft_unpacked<A: Copy + Send + Sync>(
183183
return out;
184184
}
185185

186-
let rows_per_band = ((system_info::L1_CACHE_SIZE / 2) / (dft_n_cols * size_of::<A>())).clamp(1, block_size);
186+
let rows_per_band = ((system_info::l1_cache_size() / 2) / (dft_n_cols * size_of::<A>())).clamp(1, block_size);
187187
let band_len = rows_per_band * dft_n_cols;
188188

189189
parallel::par_chunks_mut(&mut out, band_len, |band_idx, band| {
@@ -220,7 +220,7 @@ fn prepare_evals_for_fft_packed_extension<EF: ExtensionField<PF<EF>>>(
220220
return out;
221221
}
222222

223-
let rows_per_band = ((system_info::L1_CACHE_SIZE / 2) / (n_blocks * size_of::<EF>())).clamp(1, block_size);
223+
let rows_per_band = ((system_info::l1_cache_size() / 2) / (n_blocks * size_of::<EF>())).clamp(1, block_size);
224224
let band_len = rows_per_band * n_blocks;
225225

226226
parallel::par_chunks_mut(&mut out, band_len, |band_idx, band| {

0 commit comments

Comments
 (0)