Skip to main content

oxirs_core/concurrent/
thread_per_core.rs

1//! Thread-per-core architecture for optimal CPU utilization
2//!
3//! This module implements a thread-per-core work-stealing scheduler specifically
4//! optimized for RDF triple processing operations. It provides:
5//!
6//! - **CPU Affinity**: Each worker thread is pinned to a specific CPU core
7//! - **Work Stealing**: Idle threads steal work from busy threads
8//! - **NUMA Awareness**: Memory allocation considers NUMA topology
9//! - **Zero Allocation**: Lock-free work queues with bounded capacity
10//!
11//! # Architecture
12//!
13//! ```text
14//! ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐
15//! │ Core 0  │   │ Core 1  │   │ Core 2  │   │ Core 3  │
16//! │ Worker  │   │ Worker  │   │ Worker  │   │ Worker  │
17//! └────┬────┘   └────┬────┘   └────┬────┘   └────┬────┘
18//!      │             │             │             │
19//!      └─────────────┴─────────────┴─────────────┘
20//!                    Work Stealing
21//! ```
22//!
23//! # Example
24//!
25//! ```rust,ignore
26//! use oxirs_core::concurrent::thread_per_core::{ThreadPerCore, Task};
27//!
28//! # fn example() -> Result<(), oxirs_core::OxirsError> {
29//! // Create thread-per-core executor
30//! let executor = ThreadPerCore::new()?;
31//!
32//! // Submit work for parallel execution
33//! let task = Task::new(|| {
34//!     // Process RDF triples
35//!     42
36//! });
37//!
38//! let result = executor.submit(task)?;
39//! # Ok(())
40//! # }
41//! ```
42
43use crate::OxirsError;
44use crossbeam_deque::{Injector, Stealer, Worker};
45use scirs2_core::metrics::{Counter, Timer};
46use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
47use std::sync::Arc;
48use std::thread::{self, JoinHandle};
49use std::time::Duration;
50
51/// Result type
52pub type Result<T> = std::result::Result<T, OxirsError>;
53
54/// Thread-per-core executor
55pub struct ThreadPerCore {
56    /// Worker threads
57    workers: Vec<CoreWorker>,
58    /// Global work queue (injector)
59    global_queue: Arc<Injector<Task>>,
60    /// Running flag
61    running: Arc<AtomicBool>,
62    /// Configuration
63    config: ThreadPerCoreConfig,
64    /// Metrics
65    submitted_counter: Counter,
66    #[allow(dead_code)]
67    completed_counter: Counter,
68    #[allow(dead_code)]
69    stolen_counter: Counter,
70    #[allow(dead_code)]
71    execution_timer: Timer,
72}
73
74/// Configuration for thread-per-core executor
75#[derive(Debug, Clone)]
76pub struct ThreadPerCoreConfig {
77    /// Number of worker threads (defaults to available parallelism)
78    pub num_workers: usize,
79    /// Enable CPU affinity (pin threads to cores)
80    pub enable_affinity: bool,
81    /// Work queue capacity per worker
82    pub queue_capacity: usize,
83    /// Enable work stealing
84    pub enable_work_stealing: bool,
85    /// Steal batch size
86    pub steal_batch_size: usize,
87}
88
89impl Default for ThreadPerCoreConfig {
90    fn default() -> Self {
91        Self {
92            num_workers: std::thread::available_parallelism()
93                .map(|n| n.get())
94                .unwrap_or(1),
95            enable_affinity: true,
96            queue_capacity: 1024,
97            enable_work_stealing: true,
98            steal_batch_size: 16,
99        }
100    }
101}
102
103/// A task to be executed on a core
104pub struct Task {
105    /// Task function
106    func: Box<dyn FnOnce() + Send + 'static>,
107    /// Task ID for tracking
108    id: usize,
109}
110
111impl Task {
112    /// Create a new task
113    pub fn new<F>(f: F) -> Self
114    where
115        F: FnOnce() + Send + 'static,
116    {
117        static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
118        Self {
119            func: Box::new(f),
120            id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
121        }
122    }
123
124    /// Execute the task
125    fn execute(self) {
126        (self.func)();
127    }
128
129    /// Get task ID
130    pub fn id(&self) -> usize {
131        self.id
132    }
133}
134
135/// Worker thread bound to a specific core
136struct CoreWorker {
137    /// Worker ID (corresponds to CPU core)
138    #[allow(dead_code)]
139    id: usize,
140    /// Thread handle
141    handle: Option<JoinHandle<()>>,
142    /// Local work queue
143    local_queue: Worker<Task>,
144    /// Stealer for this worker (used by other workers)
145    #[allow(dead_code)]
146    stealer: Stealer<Task>,
147    /// Statistics
148    stats: Arc<WorkerStats>,
149}
150
151/// Worker statistics
152#[derive(Default)]
153struct WorkerStats {
154    /// Tasks executed
155    executed: AtomicUsize,
156    /// Tasks stolen from this worker
157    #[allow(dead_code)]
158    stolen_from: AtomicUsize,
159    /// Tasks stolen by this worker
160    stolen_by: AtomicUsize,
161    /// Idle time in microseconds
162    idle_time_us: AtomicUsize,
163}
164
165impl ThreadPerCore {
166    /// Create a new thread-per-core executor with default configuration
167    pub fn new() -> Result<Self> {
168        Self::with_config(ThreadPerCoreConfig::default())
169    }
170
171    /// Create a thread-per-core executor with custom configuration
172    pub fn with_config(config: ThreadPerCoreConfig) -> Result<Self> {
173        tracing::info!(
174            "Initializing thread-per-core executor with {} workers",
175            config.num_workers
176        );
177
178        let global_queue = Arc::new(Injector::new());
179        let running = Arc::new(AtomicBool::new(true));
180
181        // Create workers with local queues and stealers
182        let mut workers = Vec::with_capacity(config.num_workers);
183        let mut stealers = Vec::new();
184        let mut worker_stats = Vec::new();
185
186        // First, create all local queues and collect stealers
187        for worker_id in 0..config.num_workers {
188            let local_queue = Worker::new_fifo();
189            let stealer = local_queue.stealer();
190            stealers.push(stealer.clone());
191
192            let stats = Arc::new(WorkerStats::default());
193            worker_stats.push(stats.clone());
194
195            let worker = CoreWorker {
196                id: worker_id,
197                handle: None,
198                local_queue,
199                stealer,
200                stats,
201            };
202
203            workers.push(worker);
204        }
205
206        // Start worker threads (move local queues into threads)
207        let stealers_arc = Arc::new(stealers);
208
209        for (worker_id, worker) in workers.iter_mut().enumerate() {
210            // Move the local queue into the thread
211            let local_queue = std::mem::replace(&mut worker.local_queue, Worker::new_fifo());
212            let global_queue = global_queue.clone();
213            let running = running.clone();
214            let stealers = stealers_arc.clone();
215            let stats = worker_stats[worker_id].clone();
216            let enable_affinity = config.enable_affinity;
217            let enable_work_stealing = config.enable_work_stealing;
218
219            let handle = thread::Builder::new()
220                .name(format!("rdf-worker-{}", worker_id))
221                .spawn(move || {
222                    Self::worker_loop(
223                        worker_id,
224                        local_queue,
225                        global_queue,
226                        stealers,
227                        running,
228                        stats,
229                        enable_affinity,
230                        enable_work_stealing,
231                    )
232                })
233                .map_err(|e| {
234                    OxirsError::ConcurrencyError(format!("Failed to spawn worker: {}", e))
235                })?;
236
237            worker.handle = Some(handle);
238        }
239
240        Ok(Self {
241            workers,
242            global_queue,
243            running,
244            config,
245            submitted_counter: Counter::new("threadpool.submitted".to_string()),
246            completed_counter: Counter::new("threadpool.completed".to_string()),
247            stolen_counter: Counter::new("threadpool.stolen".to_string()),
248            execution_timer: Timer::new("threadpool.execution".to_string()),
249        })
250    }
251
252    /// Submit a task for execution
253    pub fn submit(&self, task: Task) -> Result<()> {
254        if !self.running.load(Ordering::Relaxed) {
255            return Err(OxirsError::ConcurrencyError(
256                "Thread pool is shutting down".to_string(),
257            ));
258        }
259
260        // Push to global queue
261        self.global_queue.push(task);
262        self.submitted_counter.add(1);
263
264        Ok(())
265    }
266
267    /// Submit multiple tasks in batch
268    pub fn submit_batch(&self, tasks: Vec<Task>) -> Result<()> {
269        if !self.running.load(Ordering::Relaxed) {
270            return Err(OxirsError::ConcurrencyError(
271                "Thread pool is shutting down".to_string(),
272            ));
273        }
274
275        for task in tasks {
276            self.global_queue.push(task);
277        }
278
279        self.submitted_counter.add(1);
280
281        Ok(())
282    }
283
284    /// Get executor statistics
285    pub fn stats(&self) -> ThreadPerCoreStats {
286        let total_executed: usize = self
287            .workers
288            .iter()
289            .map(|w| w.stats.executed.load(Ordering::Relaxed))
290            .sum();
291
292        let total_stolen: usize = self
293            .workers
294            .iter()
295            .map(|w| w.stats.stolen_by.load(Ordering::Relaxed))
296            .sum();
297
298        let total_idle_us: usize = self
299            .workers
300            .iter()
301            .map(|w| w.stats.idle_time_us.load(Ordering::Relaxed))
302            .sum();
303
304        ThreadPerCoreStats {
305            num_workers: self.config.num_workers,
306            submitted: self.submitted_counter.get(),
307            completed: total_executed as u64,
308            stolen: total_stolen as u64,
309            avg_idle_time_us: total_idle_us as f64 / self.config.num_workers as f64,
310        }
311    }
312
313    /// Worker thread main loop
314    #[allow(clippy::too_many_arguments)]
315    fn worker_loop(
316        worker_id: usize,
317        local_queue: Worker<Task>,
318        global_queue: Arc<Injector<Task>>,
319        stealers: Arc<Vec<Stealer<Task>>>,
320        running: Arc<AtomicBool>,
321        stats: Arc<WorkerStats>,
322        enable_affinity: bool,
323        enable_work_stealing: bool,
324    ) {
325        // Set CPU affinity if enabled
326        if enable_affinity {
327            if let Err(e) = Self::set_cpu_affinity(worker_id) {
328                tracing::warn!("Failed to set CPU affinity for worker {}: {}", worker_id, e);
329            } else {
330                tracing::debug!("Worker {} pinned to core {}", worker_id, worker_id);
331            }
332        }
333
334        while running.load(Ordering::Relaxed) {
335            // Try to get task from local queue
336            if let Some(task) = local_queue.pop() {
337                task.execute();
338                stats.executed.fetch_add(1, Ordering::Relaxed);
339                continue;
340            }
341
342            // Try to steal from global queue
343            match global_queue.steal() {
344                crossbeam_deque::Steal::Success(task) => {
345                    task.execute();
346                    stats.executed.fetch_add(1, Ordering::Relaxed);
347                    continue;
348                }
349                crossbeam_deque::Steal::Empty => {}
350                crossbeam_deque::Steal::Retry => continue,
351            }
352
353            // Try to steal from other workers
354            if enable_work_stealing {
355                let mut found = false;
356                for (i, stealer) in stealers.iter().enumerate() {
357                    if i == worker_id {
358                        continue; // Skip self
359                    }
360
361                    match stealer.steal() {
362                        crossbeam_deque::Steal::Success(task) => {
363                            task.execute();
364                            stats.executed.fetch_add(1, Ordering::Relaxed);
365                            stats.stolen_by.fetch_add(1, Ordering::Relaxed);
366                            found = true;
367                            break;
368                        }
369                        crossbeam_deque::Steal::Empty => {}
370                        crossbeam_deque::Steal::Retry => continue,
371                    }
372                }
373
374                if found {
375                    continue;
376                }
377            }
378
379            // No work found, sleep briefly
380            let idle_start = std::time::Instant::now();
381            thread::sleep(Duration::from_micros(10));
382            let idle_us = idle_start.elapsed().as_micros() as usize;
383            stats.idle_time_us.fetch_add(idle_us, Ordering::Relaxed);
384        }
385
386        tracing::info!("Worker {} shutting down", worker_id);
387    }
388
389    /// Set CPU affinity for current thread
390    #[cfg(target_os = "linux")]
391    fn set_cpu_affinity(core_id: usize) -> Result<()> {
392        use std::mem;
393
394        // Linux-specific CPU affinity setting
395        unsafe {
396            let mut cpu_set: libc::cpu_set_t = mem::zeroed();
397            libc::CPU_SET(core_id, &mut cpu_set);
398
399            if libc::sched_setaffinity(0, mem::size_of::<libc::cpu_set_t>(), &cpu_set) != 0 {
400                return Err(OxirsError::ConcurrencyError(format!(
401                    "Failed to set CPU affinity: {}",
402                    std::io::Error::last_os_error()
403                )));
404            }
405        }
406
407        Ok(())
408    }
409
410    /// Set CPU affinity for current thread (no-op on non-Linux)
411    #[cfg(not(target_os = "linux"))]
412    fn set_cpu_affinity(_core_id: usize) -> Result<()> {
413        // CPU affinity not supported on this platform
414        Ok(())
415    }
416
417    /// Shutdown the thread pool gracefully
418    pub fn shutdown(self) -> Result<()> {
419        tracing::info!("Shutting down thread-per-core executor");
420
421        // Signal workers to stop
422        self.running.store(false, Ordering::Relaxed);
423
424        // Wait for all workers to finish
425        for mut worker in self.workers {
426            if let Some(handle) = worker.handle.take() {
427                handle.join().map_err(|_| {
428                    OxirsError::ConcurrencyError("Worker thread panicked".to_string())
429                })?;
430            }
431        }
432
433        tracing::info!("Thread-per-core executor shut down successfully");
434        Ok(())
435    }
436}
437
438impl Default for ThreadPerCore {
439    fn default() -> Self {
440        Self::new().expect("Failed to create ThreadPerCore executor")
441    }
442}
443
444/// Statistics for thread-per-core executor
445#[derive(Debug, Clone)]
446pub struct ThreadPerCoreStats {
447    /// Number of worker threads
448    pub num_workers: usize,
449    /// Total tasks submitted
450    pub submitted: u64,
451    /// Total tasks completed
452    pub completed: u64,
453    /// Total tasks stolen
454    pub stolen: u64,
455    /// Average idle time per worker (microseconds)
456    pub avg_idle_time_us: f64,
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use std::sync::atomic::AtomicUsize;
463    use std::sync::Arc;
464
465    #[test]
466    fn test_thread_per_core_creation() -> Result<()> {
467        let config = ThreadPerCoreConfig {
468            num_workers: 4,
469            ..Default::default()
470        };
471
472        let executor = ThreadPerCore::with_config(config)?;
473        executor.shutdown()?;
474
475        Ok(())
476    }
477
478    #[test]
479    fn test_task_submission() -> Result<()> {
480        let executor = ThreadPerCore::new()?;
481
482        let counter = Arc::new(AtomicUsize::new(0));
483        let counter_clone = counter.clone();
484
485        let task = Task::new(move || {
486            counter_clone.fetch_add(1, Ordering::Relaxed);
487        });
488
489        executor.submit(task)?;
490
491        // Give time for execution
492        thread::sleep(Duration::from_millis(100));
493
494        assert_eq!(counter.load(Ordering::Relaxed), 1);
495
496        executor.shutdown()?;
497        Ok(())
498    }
499
500    #[test]
501    fn test_batch_submission() -> Result<()> {
502        let executor = ThreadPerCore::new()?;
503
504        let counter = Arc::new(AtomicUsize::new(0));
505
506        let tasks: Vec<_> = (0..100)
507            .map(|_| {
508                let counter = counter.clone();
509                Task::new(move || {
510                    counter.fetch_add(1, Ordering::Relaxed);
511                })
512            })
513            .collect();
514
515        executor.submit_batch(tasks)?;
516
517        // Give time for execution
518        thread::sleep(Duration::from_millis(500));
519
520        assert_eq!(counter.load(Ordering::Relaxed), 100);
521
522        executor.shutdown()?;
523        Ok(())
524    }
525
526    #[test]
527    fn test_stats() -> Result<()> {
528        let executor = ThreadPerCore::new()?;
529
530        // Submit some tasks
531        for _ in 0..10 {
532            let task = Task::new(|| {
533                thread::sleep(Duration::from_millis(1));
534            });
535            executor.submit(task)?;
536        }
537
538        // Give time for execution
539        thread::sleep(Duration::from_millis(100));
540
541        let stats = executor.stats();
542        assert_eq!(stats.submitted, 10);
543        assert!(stats.completed <= 10);
544
545        executor.shutdown()?;
546        Ok(())
547    }
548}