Skip to main content

hataori/
domain.rs

1use std::fmt;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4/// The P0 execution-domain identifier.
5///
6/// P0 supports only [`DomainId::ZERO`]. Other numeric identifiers are rejected
7/// by [`TryFrom`].
8#[repr(transparent)]
9#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
10pub struct DomainId(u32);
11
12impl DomainId {
13    /// The only domain identifier supported by P0.
14    pub const ZERO: Self = Self(0);
15
16    /// Returns the numeric identifier.
17    pub const fn get(self) -> u32 {
18        self.0
19    }
20}
21
22impl TryFrom<u32> for DomainId {
23    type Error = UnsupportedDomainId;
24
25    fn try_from(requested: u32) -> Result<Self, Self::Error> {
26        match requested {
27            0 => Ok(Self::ZERO),
28            requested => Err(UnsupportedDomainId { requested }),
29        }
30    }
31}
32
33/// The requested domain identifier is not supported by P0.
34#[derive(Debug, Copy, Clone, Eq, PartialEq)]
35pub struct UnsupportedDomainId {
36    requested: u32,
37}
38
39impl UnsupportedDomainId {
40    /// Returns the unsupported identifier that was requested.
41    pub const fn requested(self) -> u32 {
42        self.requested
43    }
44}
45
46impl fmt::Display for UnsupportedDomainId {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(formatter, "unsupported domain ID: {}", self.requested)
49    }
50}
51
52impl std::error::Error for UnsupportedDomainId {}
53
54/// A rank and its execution-domain identifier.
55///
56/// P0 uses zero-based non-negative ranks and only accepts the supported domain
57/// identifiers. [`Place::new`] rejects a negative rank.
58#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
59pub struct Place {
60    rank: i32,
61    domain_id: DomainId,
62}
63
64impl Place {
65    /// Creates a place, rejecting a negative rank.
66    pub fn new(rank: i32, domain_id: DomainId) -> Result<Self, NegativeRank> {
67        if rank < 0 {
68            return Err(NegativeRank { requested: rank });
69        }
70        Ok(Self { rank, domain_id })
71    }
72
73    /// Returns the non-negative rank.
74    pub const fn rank(self) -> i32 {
75        self.rank
76    }
77
78    /// Returns the execution-domain identifier.
79    pub const fn domain_id(self) -> DomainId {
80        self.domain_id
81    }
82}
83
84/// A negative rank was supplied to [`Place::new`].
85#[derive(Debug, Copy, Clone, Eq, PartialEq)]
86pub struct NegativeRank {
87    requested: i32,
88}
89
90impl NegativeRank {
91    /// Returns the negative rank that was requested.
92    pub const fn requested(self) -> i32 {
93        self.requested
94    }
95}
96
97impl fmt::Display for NegativeRank {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(formatter, "rank must be non-negative: {}", self.requested)
100    }
101}
102
103impl std::error::Error for NegativeRank {}
104
105/// Local execution policy for a domain operation.
106#[derive(Debug, Copy, Clone, Eq, PartialEq)]
107pub enum LocalMode {
108    /// Run callbacks one at a time in the selected pool and stop at the first error.
109    Sequential,
110    /// Run every callback in parallel in the selected pool, preserve input order,
111    /// and report the lowest callback-error index after all callbacks finish.
112    Outer,
113    /// Run callbacks one at a time in the selected pool and allow nested work in
114    /// that same pool; stop at the first error.
115    Inner,
116}
117
118#[cfg(feature = "rayon")]
119/// Whether a Rayon pool is owned by Hataori or by its caller.
120#[derive(Debug, Copy, Clone, Eq, PartialEq)]
121pub enum PoolOwnership {
122    /// The domain created and owns the pool.
123    Managed,
124    /// The pool belongs to the caller.
125    External,
126}
127
128#[cfg(feature = "rayon")]
129/// Whether the domain's CPU placement was verified.
130#[derive(Debug, Copy, Clone, Eq, PartialEq)]
131pub enum PlacementStatus {
132    /// Linux worker startup pinned and verified every worker.
133    Verified,
134    /// Placement is a declaration supplied by the caller.
135    CallerDeclared,
136}
137
138#[cfg(feature = "rayon")]
139/// A managed or external Rayon domain could not be constructed.
140pub enum DomainBuildError {
141    EmptyCpuSet,
142    DuplicateCpu {
143        cpu: usize,
144    },
145    ZeroWorkers,
146    TooManyWorkers {
147        worker_count: usize,
148        cpu_count: usize,
149    },
150    CpuOutOfRange {
151        cpu: usize,
152        cpu_set_size: usize,
153    },
154    CpuNotAllowed {
155        cpu: usize,
156    },
157    AffinityQuery {
158        errno: i32,
159    },
160    PoolBuild {
161        message: String,
162    },
163    PinFailed {
164        worker_index: usize,
165        cpu: usize,
166        message: String,
167    },
168    StartTimeout {
169        expected: usize,
170        received: usize,
171    },
172    InvalidReport {
173        worker_index: usize,
174        cpu: usize,
175    },
176    PoolSizeMismatch {
177        expected: usize,
178        actual: usize,
179    },
180}
181
182#[cfg(feature = "rayon")]
183impl fmt::Debug for DomainBuildError {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        match self {
186            Self::EmptyCpuSet => formatter.write_str("EmptyCpuSet"),
187            Self::DuplicateCpu { cpu } => formatter
188                .debug_struct("DuplicateCpu")
189                .field("cpu", cpu)
190                .finish(),
191            Self::ZeroWorkers => formatter.write_str("ZeroWorkers"),
192            Self::TooManyWorkers {
193                worker_count,
194                cpu_count,
195            } => formatter
196                .debug_struct("TooManyWorkers")
197                .field("worker_count", worker_count)
198                .field("cpu_count", cpu_count)
199                .finish(),
200            Self::CpuOutOfRange { cpu, cpu_set_size } => formatter
201                .debug_struct("CpuOutOfRange")
202                .field("cpu", cpu)
203                .field("cpu_set_size", cpu_set_size)
204                .finish(),
205            Self::CpuNotAllowed { cpu } => formatter
206                .debug_struct("CpuNotAllowed")
207                .field("cpu", cpu)
208                .finish(),
209            Self::AffinityQuery { errno } => formatter
210                .debug_struct("AffinityQuery")
211                .field("errno", errno)
212                .finish(),
213            Self::PoolBuild { message } => formatter
214                .debug_struct("PoolBuild")
215                .field("message", message)
216                .finish(),
217            Self::PinFailed {
218                worker_index,
219                cpu,
220                message,
221            } => formatter
222                .debug_struct("PinFailed")
223                .field("worker_index", worker_index)
224                .field("cpu", cpu)
225                .field("message", message)
226                .finish(),
227            Self::StartTimeout { expected, received } => formatter
228                .debug_struct("StartTimeout")
229                .field("expected", expected)
230                .field("received", received)
231                .finish(),
232            Self::InvalidReport { worker_index, cpu } => formatter
233                .debug_struct("InvalidReport")
234                .field("worker_index", worker_index)
235                .field("cpu", cpu)
236                .finish(),
237            Self::PoolSizeMismatch { expected, actual } => formatter
238                .debug_struct("PoolSizeMismatch")
239                .field("expected", expected)
240                .field("actual", actual)
241                .finish(),
242        }
243    }
244}
245
246#[cfg(feature = "rayon")]
247impl fmt::Display for DomainBuildError {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        match self {
250            Self::EmptyCpuSet => formatter.write_str("CPU set must not be empty"),
251            Self::DuplicateCpu { cpu } => write!(formatter, "CPU set contains duplicate CPU {cpu}"),
252            Self::ZeroWorkers => formatter.write_str("worker count must be greater than zero"),
253            Self::TooManyWorkers {
254                worker_count,
255                cpu_count,
256            } => write!(
257                formatter,
258                "worker count {worker_count} exceeds CPU set size {cpu_count}"
259            ),
260            Self::CpuOutOfRange { cpu, cpu_set_size } => {
261                write!(
262                    formatter,
263                    "CPU {cpu} is outside CPU set size {cpu_set_size}"
264                )
265            }
266            Self::CpuNotAllowed { cpu } => write!(formatter, "CPU {cpu} is not process-allowed"),
267            Self::AffinityQuery { errno } => write!(
268                formatter,
269                "could not query process affinity (errno {errno})"
270            ),
271            Self::PoolBuild { message } => {
272                write!(formatter, "could not build Rayon pool: {message}")
273            }
274            Self::PinFailed {
275                worker_index,
276                cpu,
277                message,
278            } => write!(
279                formatter,
280                "worker {worker_index} could not be placed on CPU {cpu}: {message}"
281            ),
282            Self::StartTimeout { expected, received } => write!(
283                formatter,
284                "timed out waiting for Rayon worker startup reports ({received}/{expected})"
285            ),
286            Self::InvalidReport { worker_index, cpu } => write!(
287                formatter,
288                "invalid Rayon worker startup report for worker {worker_index} on CPU {cpu}"
289            ),
290            Self::PoolSizeMismatch { expected, actual } => write!(
291                formatter,
292                "external Rayon pool has {actual} workers, expected {expected}"
293            ),
294        }
295    }
296}
297
298#[cfg(feature = "rayon")]
299impl std::error::Error for DomainBuildError {}
300
301/// A P0 execution domain.
302///
303/// `Domain::sequential` creates domain zero with one worker and no Rayon pool.
304/// At most one coarse operation may be admitted at a time; [`Domain::try_admit`]
305/// returns [`DomainBusy`] immediately instead of waiting when another operation
306/// owns the domain.
307///
308/// # Example
309///
310/// ```
311/// use hataori::{Domain, DomainBusy};
312///
313/// let domain = Domain::sequential();
314/// let admission = domain.try_admit().unwrap();
315/// assert_eq!(domain.try_admit().unwrap_err(), DomainBusy);
316/// drop(admission);
317/// assert!(domain.try_admit().is_ok());
318/// ```
319// No Clone: a domain owns admission state and, with Rayon, a pool handle.
320pub struct Domain {
321    id: DomainId,
322    worker_count: usize,
323    running: AtomicBool,
324    #[cfg(feature = "rayon")]
325    pool: Option<std::sync::Arc<rayon::ThreadPool>>,
326    #[cfg(feature = "rayon")]
327    cpu_set: Option<Vec<usize>>,
328    #[cfg(feature = "rayon")]
329    ownership: Option<PoolOwnership>,
330    #[cfg(feature = "rayon")]
331    placement: Option<PlacementStatus>,
332}
333
334impl fmt::Debug for Domain {
335    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
336        let mut debug = formatter.debug_struct("Domain");
337        debug
338            .field("id", &self.id)
339            .field("worker_count", &self.worker_count)
340            .field("running", &self.running.load(Ordering::Relaxed));
341        #[cfg(feature = "rayon")]
342        debug
343            .field("has_pool", &self.pool.is_some())
344            .field("cpu_set", &self.cpu_set)
345            .field("ownership", &self.ownership)
346            .field("placement", &self.placement);
347        debug.finish()
348    }
349}
350
351impl Domain {
352    /// Creates the P0 sequential domain: ID zero, one worker, and no running
353    /// operation.
354    pub fn sequential() -> Self {
355        Self {
356            id: DomainId::ZERO,
357            worker_count: 1,
358            running: AtomicBool::new(false),
359            #[cfg(feature = "rayon")]
360            pool: None,
361            #[cfg(feature = "rayon")]
362            cpu_set: None,
363            #[cfg(feature = "rayon")]
364            ownership: None,
365            #[cfg(feature = "rayon")]
366            placement: None,
367        }
368    }
369
370    /// Returns the domain identifier.
371    pub const fn id(&self) -> DomainId {
372        self.id
373    }
374
375    /// Returns the declared worker count.
376    pub const fn worker_count(&self) -> usize {
377        self.worker_count
378    }
379
380    /// Attempts to admit one coarse operation without waiting.
381    pub fn try_admit(&self) -> Result<DomainAdmission<'_>, DomainBusy> {
382        self.running
383            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
384            .map(|_| DomainAdmission { domain: self })
385            .map_err(|_| DomainBusy)
386    }
387
388    #[cfg(feature = "rayon")]
389    /// Creates and owns a Rayon pool, verifying worker affinity on Linux.
390    pub fn managed(cpu_set: Vec<usize>, worker_count: usize) -> Result<Self, DomainBuildError> {
391        validate_cpu_set(&cpu_set, worker_count)?;
392        #[cfg(target_os = "linux")]
393        {
394            validate_linux_cpu_range(&cpu_set)?;
395            let allowed = process_allowed_cpus()?;
396            for &cpu in &cpu_set {
397                if !allowed.contains(&cpu) {
398                    return Err(DomainBuildError::CpuNotAllowed { cpu });
399                }
400            }
401        }
402        build_managed(cpu_set, worker_count, std::sync::Arc::new(DefaultPinner))
403    }
404
405    #[cfg(feature = "rayon")]
406    /// Uses a caller-owned Rayon pool without changing its worker affinity or
407    /// shutting it down when this domain is dropped.
408    pub fn external(
409        pool: std::sync::Arc<rayon::ThreadPool>,
410        declared_cpu_set: Vec<usize>,
411        worker_count: usize,
412    ) -> Result<Self, DomainBuildError> {
413        validate_cpu_set(&declared_cpu_set, worker_count)?;
414        #[cfg(target_os = "linux")]
415        validate_linux_cpu_range(&declared_cpu_set)?;
416        let actual = pool.current_num_threads();
417        if actual != worker_count {
418            return Err(DomainBuildError::PoolSizeMismatch {
419                expected: worker_count,
420                actual,
421            });
422        }
423        Ok(Self {
424            id: DomainId::ZERO,
425            worker_count,
426            running: AtomicBool::new(false),
427            pool: Some(pool),
428            cpu_set: Some(declared_cpu_set),
429            ownership: Some(PoolOwnership::External),
430            placement: Some(PlacementStatus::CallerDeclared),
431        })
432    }
433
434    #[cfg(feature = "rayon")]
435    /// Returns the declared or verified CPU set, when this domain has a pool.
436    pub fn cpu_set(&self) -> Option<&[usize]> {
437        self.cpu_set.as_deref()
438    }
439
440    #[cfg(feature = "rayon")]
441    /// Returns the pool ownership mode, when this domain has a pool.
442    pub fn pool_ownership(&self) -> Option<PoolOwnership> {
443        self.ownership
444    }
445
446    #[cfg(feature = "rayon")]
447    /// Returns the placement status, when this domain has a pool.
448    pub fn placement_status(&self) -> Option<PlacementStatus> {
449        self.placement
450    }
451
452    #[cfg(feature = "rayon")]
453    /// Clones the Rayon pool handle associated with this domain, when present.
454    ///
455    /// This grants no domain admission. Submitting work directly to the returned
456    /// pool bypasses Hataori scheduling; integration adapters must enter through
457    /// an already admitted [`LocalMode::Inner`] callback.
458    ///
459    /// # Examples
460    ///
461    /// ```
462    /// # #[cfg(feature = "rayon")]
463    /// # {
464    /// use hataori::Domain;
465    /// use std::sync::Arc;
466    ///
467    /// let pool = Arc::new(rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap());
468    /// let domain = Domain::external(Arc::clone(&pool), vec![0], 1).unwrap();
469    /// assert!(Arc::ptr_eq(&pool, &domain.rayon_pool_handle().unwrap()));
470    /// # }
471    /// ```
472    pub fn rayon_pool_handle(&self) -> Option<std::sync::Arc<rayon::ThreadPool>> {
473        self.pool.clone()
474    }
475
476    #[cfg(feature = "rayon")]
477    pub(crate) fn rayon_pool(&self) -> Option<&std::sync::Arc<rayon::ThreadPool>> {
478        self.pool.as_ref()
479    }
480}
481
482#[cfg(feature = "rayon")]
483fn validate_cpu_set(cpu_set: &[usize], worker_count: usize) -> Result<(), DomainBuildError> {
484    if cpu_set.is_empty() {
485        return Err(DomainBuildError::EmptyCpuSet);
486    }
487    let mut seen = std::collections::HashSet::with_capacity(cpu_set.len());
488    for &cpu in cpu_set {
489        if !seen.insert(cpu) {
490            return Err(DomainBuildError::DuplicateCpu { cpu });
491        }
492    }
493    if worker_count == 0 {
494        return Err(DomainBuildError::ZeroWorkers);
495    }
496    if worker_count > cpu_set.len() {
497        return Err(DomainBuildError::TooManyWorkers {
498            worker_count,
499            cpu_count: cpu_set.len(),
500        });
501    }
502    Ok(())
503}
504
505#[cfg(all(feature = "rayon", target_os = "linux"))]
506fn validate_linux_cpu_range(cpu_set: &[usize]) -> Result<(), DomainBuildError> {
507    let cpu_set_size = libc::CPU_SETSIZE as usize;
508    for &cpu in cpu_set {
509        if cpu >= cpu_set_size {
510            return Err(DomainBuildError::CpuOutOfRange { cpu, cpu_set_size });
511        }
512    }
513    Ok(())
514}
515
516#[cfg(all(feature = "rayon", target_os = "linux"))]
517fn process_allowed_cpus() -> Result<std::collections::HashSet<usize>, DomainBuildError> {
518    let mut allowed = unsafe { std::mem::zeroed::<libc::cpu_set_t>() };
519    let result =
520        unsafe { libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut allowed) };
521    if result != 0 {
522        return Err(DomainBuildError::AffinityQuery {
523            errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(-1),
524        });
525    }
526    let mut cpus = std::collections::HashSet::new();
527    for cpu in 0..libc::CPU_SETSIZE as usize {
528        if unsafe { libc::CPU_ISSET(cpu, &allowed) } {
529            cpus.insert(cpu);
530        }
531    }
532    Ok(cpus)
533}
534
535#[cfg(feature = "rayon")]
536trait Pinner: Send + Sync {
537    fn pin_and_verify(&self, cpu: usize) -> Result<(), String>;
538}
539
540#[cfg(feature = "rayon")]
541struct DefaultPinner;
542
543#[cfg(all(feature = "rayon", target_os = "linux"))]
544impl Pinner for DefaultPinner {
545    fn pin_and_verify(&self, cpu: usize) -> Result<(), String> {
546        let mut set = unsafe { std::mem::zeroed::<libc::cpu_set_t>() };
547        unsafe { libc::CPU_ZERO(&mut set) };
548        unsafe { libc::CPU_SET(cpu, &mut set) };
549        let result = unsafe {
550            libc::pthread_setaffinity_np(
551                libc::pthread_self(),
552                std::mem::size_of::<libc::cpu_set_t>(),
553                &set,
554            )
555        };
556        if result != 0 {
557            return Err(format!("pthread_setaffinity_np failed (errno {result})"));
558        }
559        let actual = unsafe { libc::sched_getcpu() };
560        if actual != cpu as i32 {
561            return Err(format!("worker ran on CPU {actual}"));
562        }
563        Ok(())
564    }
565}
566
567#[cfg(all(feature = "rayon", not(target_os = "linux")))]
568impl Pinner for DefaultPinner {
569    fn pin_and_verify(&self, _cpu: usize) -> Result<(), String> {
570        Ok(())
571    }
572}
573
574#[cfg(feature = "rayon")]
575struct WorkerReport {
576    index: usize,
577    cpu: usize,
578    result: Result<(), String>,
579}
580
581#[cfg(feature = "rayon")]
582fn build_managed(
583    cpu_set: Vec<usize>,
584    worker_count: usize,
585    pinner: std::sync::Arc<dyn Pinner>,
586) -> Result<Domain, DomainBuildError> {
587    let expected_cpus = cpu_set.clone();
588    let start_cpus = expected_cpus.clone();
589    let (sender, receiver) = std::sync::mpsc::channel();
590    let builder = rayon::ThreadPoolBuilder::new()
591        .num_threads(worker_count)
592        .thread_name(|index| format!("hataori-worker-{index}"))
593        .start_handler(move |index| {
594            let cpu = start_cpus.get(index).copied().unwrap_or(usize::MAX);
595            let result = if cpu == usize::MAX {
596                Err(String::from("worker index is outside the declared CPU set"))
597            } else {
598                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
599                    pinner.pin_and_verify(cpu)
600                }))
601                .unwrap_or_else(|_| Err(String::from("worker pinner panicked")))
602            };
603            let _ = sender.send(WorkerReport { index, cpu, result });
604        });
605    let pool = builder
606        .build()
607        .map_err(|error| DomainBuildError::PoolBuild {
608            message: error.to_string(),
609        })?;
610
611    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
612    let mut seen = vec![false; worker_count];
613    for received in 0..worker_count {
614        let report = match receiver
615            .recv_timeout(deadline.saturating_duration_since(std::time::Instant::now()))
616        {
617            Ok(report) => report,
618            Err(_) => {
619                drop(pool);
620                return Err(DomainBuildError::StartTimeout {
621                    expected: worker_count,
622                    received,
623                });
624            }
625        };
626        if report.index >= worker_count
627            || seen[report.index]
628            || expected_cpus.get(report.index).copied() != Some(report.cpu)
629        {
630            drop(pool);
631            return Err(DomainBuildError::InvalidReport {
632                worker_index: report.index,
633                cpu: report.cpu,
634            });
635        }
636        seen[report.index] = true;
637        if let Err(message) = report.result {
638            drop(pool);
639            return Err(DomainBuildError::PinFailed {
640                worker_index: report.index,
641                cpu: report.cpu,
642                message,
643            });
644        }
645    }
646
647    Ok(Domain {
648        id: DomainId::ZERO,
649        worker_count,
650        running: AtomicBool::new(false),
651        pool: Some(std::sync::Arc::new(pool)),
652        cpu_set: Some(cpu_set),
653        ownership: Some(PoolOwnership::Managed),
654        placement: Some(if cfg!(target_os = "linux") {
655            PlacementStatus::Verified
656        } else {
657            PlacementStatus::CallerDeclared
658        }),
659    })
660}
661
662/// RAII ownership of a domain's one running-operation slot.
663///
664/// Dropping the admission releases the slot with release ordering. The guard
665/// is scoped and does not provide a waiting or reentrant admission path.
666#[must_use]
667#[derive(Debug)]
668pub struct DomainAdmission<'a> {
669    domain: &'a Domain,
670}
671
672impl Drop for DomainAdmission<'_> {
673    fn drop(&mut self) {
674        self.domain.running.store(false, Ordering::Release);
675    }
676}
677
678/// The domain's admission slot is already occupied.
679#[derive(Debug, Copy, Clone, Eq, PartialEq)]
680pub struct DomainBusy;
681
682impl fmt::Display for DomainBusy {
683    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
684        formatter.write_str("domain is busy")
685    }
686}
687
688impl std::error::Error for DomainBusy {}
689
690#[cfg(test)]
691mod tests;