tenferro_cpu/affinity_policy.rs
1use std::collections::BTreeMap;
2
3use smallvec::SmallVec;
4use tenferro_tensor::{CpuDomainId, DType, Tensor};
5
6const INLINE_DOMAIN_CAPACITY: usize = 8;
7
8/// Policy used to select a CPU execution domain from input affinity metadata.
9///
10/// # Examples
11///
12/// ```rust
13/// use tenferro_cpu::CpuAffinityPolicy;
14///
15/// let policy = CpuAffinityPolicy::DominantInputBytes;
16/// assert_ne!(policy, CpuAffinityPolicy::RequireSingleDomain);
17/// ```
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum CpuAffinityPolicy {
20 /// Select the domain with the largest total of positive logical input bytes.
21 DominantInputBytes,
22 /// Accept zero or one known input domain and reject mixed known domains.
23 RequireSingleDomain,
24}
25
26/// CPU affinity metadata for one logical operation input.
27///
28/// The resolver reads this metadata only. It never changes, copies, or rehomes
29/// tensor payloads.
30///
31/// # Examples
32///
33/// ```rust
34/// use tenferro_cpu::CpuAffinityInput;
35/// use tenferro_tensor::CpuDomainId;
36///
37/// let input = CpuAffinityInput {
38/// domain: Some(CpuDomainId::new(3)),
39/// logical_bytes: 64,
40/// };
41/// assert_eq!(input.logical_bytes, 64);
42/// ```
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub struct CpuAffinityInput {
45 /// Known CPU execution domain, or `None` when affinity is unknown.
46 pub domain: Option<CpuDomainId>,
47 /// Logical input size used by [`CpuAffinityPolicy::DominantInputBytes`].
48 pub logical_bytes: usize,
49}
50
51impl CpuAffinityInput {
52 /// Construct resolver input metadata from a tensor.
53 ///
54 /// The logical byte count is the checked shape product times the tensor's
55 /// scalar byte width. CPU affinity is copied from placement metadata; the
56 /// tensor and its storage are otherwise untouched.
57 ///
58 /// # Examples
59 ///
60 /// ```rust
61 /// use tenferro_cpu::CpuAffinityInput;
62 /// use tenferro_tensor::Tensor;
63 ///
64 /// let tensor = Tensor::from_vec_col_major(vec![2], vec![1.0_f64, 2.0])?;
65 /// let input = CpuAffinityInput::from_tensor(&tensor)?;
66 /// assert_eq!(input.logical_bytes, 2 * std::mem::size_of::<f64>());
67 /// # Ok::<(), Box<dyn std::error::Error>>(())
68 /// ```
69 ///
70 /// # Errors
71 ///
72 /// Returns [`CpuAffinityInputError`] when the logical element or byte
73 /// count cannot be represented by `usize`.
74 pub fn from_tensor(tensor: &Tensor) -> Result<Self, CpuAffinityInputError> {
75 Self::from_parts(
76 tensor.placement().cpu_affinity,
77 tensor.shape(),
78 tensor.dtype(),
79 )
80 }
81
82 /// Construct resolver input metadata from placement, shape, and dtype.
83 ///
84 /// Scalar shapes have one element. Any zero extent yields zero logical
85 /// bytes without multiplying the other extents.
86 ///
87 /// # Examples
88 ///
89 /// ```rust
90 /// use tenferro_cpu::CpuAffinityInput;
91 /// use tenferro_tensor::DType;
92 ///
93 /// let scalar = CpuAffinityInput::from_parts(None, &[], DType::F32)?;
94 /// let empty = CpuAffinityInput::from_parts(None, &[usize::MAX, 0], DType::F64)?;
95 /// assert_eq!(scalar.logical_bytes, 4);
96 /// assert_eq!(empty.logical_bytes, 0);
97 /// # Ok::<(), tenferro_cpu::CpuAffinityInputError>(())
98 /// ```
99 ///
100 /// # Errors
101 ///
102 /// Returns [`CpuAffinityInputError::ShapeProductOverflow`] when non-zero
103 /// extents overflow, or
104 /// [`CpuAffinityInputError::LogicalByteCountOverflow`] when multiplying by
105 /// the dtype width overflows.
106 pub fn from_parts(
107 domain: Option<CpuDomainId>,
108 shape: &[usize],
109 dtype: DType,
110 ) -> Result<Self, CpuAffinityInputError> {
111 let element_count = if shape.contains(&0) {
112 0
113 } else {
114 shape.iter().try_fold(1_usize, |count, &extent| {
115 count
116 .checked_mul(extent)
117 .ok_or(CpuAffinityInputError::ShapeProductOverflow)
118 })?
119 };
120 let byte_width = dtype_byte_width(dtype);
121 let logical_bytes = element_count.checked_mul(byte_width).ok_or(
122 CpuAffinityInputError::LogicalByteCountOverflow {
123 element_count,
124 byte_width,
125 },
126 )?;
127 Ok(Self {
128 domain,
129 logical_bytes,
130 })
131 }
132}
133
134const fn dtype_byte_width(dtype: DType) -> usize {
135 match dtype {
136 DType::F32 | DType::I32 => std::mem::size_of::<u32>(),
137 DType::F64 | DType::I64 => std::mem::size_of::<u64>(),
138 DType::Bool => std::mem::size_of::<bool>(),
139 DType::C32 => std::mem::size_of::<num_complex::Complex32>(),
140 DType::C64 => std::mem::size_of::<num_complex::Complex64>(),
141 }
142}
143
144/// Failure to derive logical input bytes for CPU affinity resolution.
145///
146/// # Examples
147///
148/// ```rust
149/// use tenferro_cpu::{CpuAffinityInput, CpuAffinityInputError};
150/// use tenferro_tensor::DType;
151///
152/// let error = CpuAffinityInput::from_parts(None, &[usize::MAX, 2], DType::F32)
153/// .unwrap_err();
154/// assert_eq!(error, CpuAffinityInputError::ShapeProductOverflow);
155/// ```
156#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
157pub enum CpuAffinityInputError {
158 /// Multiplying non-zero shape extents overflowed `usize`.
159 #[error("logical tensor element count overflowed usize")]
160 ShapeProductOverflow,
161 /// Multiplying element count by dtype width overflowed `usize`.
162 #[error(
163 "logical tensor byte count overflowed: element_count={element_count}, byte_width={byte_width}"
164 )]
165 LogicalByteCountOverflow {
166 /// Checked logical element count.
167 element_count: usize,
168 /// Scalar dtype width in bytes.
169 byte_width: usize,
170 },
171}
172
173/// Why the CPU affinity resolver selected a domain.
174///
175/// # Examples
176///
177/// ```rust
178/// use tenferro_cpu::CpuAffinitySelectionReason;
179///
180/// let reason = CpuAffinitySelectionReason::DefaultDomain;
181/// assert_eq!(reason, CpuAffinitySelectionReason::DefaultDomain);
182/// ```
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub enum CpuAffinitySelectionReason {
185 /// An operation-local explicit domain override took precedence.
186 ExplicitOverride,
187 /// Positive logical bytes made this domain dominant.
188 DominantInputBytes,
189 /// Strict policy observed exactly one known input domain.
190 SingleInputDomain,
191 /// No relevant input affinity was available.
192 DefaultDomain,
193}
194
195/// Deterministic CPU affinity selection returned by the pure resolver.
196///
197/// # Examples
198///
199/// ```rust
200/// use tenferro_cpu::{CpuAffinitySelection, CpuAffinitySelectionReason};
201/// use tenferro_tensor::CpuDomainId;
202///
203/// let selection = CpuAffinitySelection {
204/// domain: CpuDomainId::new(2),
205/// reason: CpuAffinitySelectionReason::DominantInputBytes,
206/// };
207/// assert_eq!(selection.domain.as_u64(), 2);
208/// ```
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
210pub struct CpuAffinitySelection {
211 /// Selected CPU execution domain.
212 pub domain: CpuDomainId,
213 /// Deterministic reason for the selection.
214 pub reason: CpuAffinitySelectionReason,
215}
216
217/// Failure to resolve CPU affinity from input metadata.
218///
219/// # Examples
220///
221/// ```rust
222/// use tenferro_cpu::CpuAffinityResolutionError;
223/// use tenferro_tensor::CpuDomainId;
224///
225/// let error = CpuAffinityResolutionError::LogicalByteCountOverflow {
226/// domain: CpuDomainId::new(4),
227/// };
228/// assert!(error.to_string().contains("4"));
229/// ```
230#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
231pub enum CpuAffinityResolutionError {
232 /// Adding logical byte counts overflowed `usize` for one domain.
233 #[error("logical input-byte total overflowed for CPU domain {domain:?}")]
234 LogicalByteCountOverflow {
235 /// Smallest CPU domain whose logical byte total overflowed.
236 domain: CpuDomainId,
237 },
238 /// Strict policy observed at least two different known domains.
239 #[error("CPU affinity policy requires one input domain, found {first:?} and {second:?}")]
240 MultipleKnownDomains {
241 /// Smallest known input domain.
242 first: CpuDomainId,
243 /// Second-smallest known input domain.
244 second: CpuDomainId,
245 },
246}
247
248/// Resolve a CPU execution domain from input affinity metadata.
249///
250/// Unknown affinities and zero-byte inputs do not contribute to dominant-byte
251/// scoring. When no input contributes, `default_domain` is selected. Equal
252/// positive totals are resolved in favor of the smallest [`CpuDomainId`]. The
253/// input slice is only read; the resolver never retags or rehomes an input.
254///
255/// Use [`resolve_cpu_affinity_with_override`] when an operation-local explicit
256/// placement has already been selected.
257///
258/// # Examples
259///
260/// ```rust
261/// use tenferro_cpu::{resolve_cpu_affinity, CpuAffinityInput, CpuAffinityPolicy};
262/// use tenferro_tensor::CpuDomainId;
263///
264/// let inputs = [
265/// CpuAffinityInput { domain: Some(CpuDomainId::new(8)), logical_bytes: 6 },
266/// CpuAffinityInput { domain: Some(CpuDomainId::new(3)), logical_bytes: 2 },
267/// ];
268/// let selected = resolve_cpu_affinity(
269/// CpuAffinityPolicy::DominantInputBytes,
270/// &inputs,
271/// CpuDomainId::new(1),
272/// )?;
273/// assert_eq!(selected.domain, CpuDomainId::new(8));
274/// # Ok::<(), tenferro_cpu::CpuAffinityResolutionError>(())
275/// ```
276///
277/// # Errors
278///
279/// Returns [`CpuAffinityResolutionError::LogicalByteCountOverflow`] when one
280/// domain's logical byte total cannot be represented by `usize`, or
281/// [`CpuAffinityResolutionError::MultipleKnownDomains`] when strict policy sees
282/// more than one known input domain.
283pub fn resolve_cpu_affinity(
284 policy: CpuAffinityPolicy,
285 inputs: &[CpuAffinityInput],
286 default_domain: CpuDomainId,
287) -> Result<CpuAffinitySelection, CpuAffinityResolutionError> {
288 resolve_cpu_affinity_with_override(policy, inputs, default_domain, None)
289}
290
291/// Resolve CPU affinity with an optional operation-local explicit override.
292///
293/// Explicit placement takes precedence before input-byte accounting or strict
294/// mixed-domain validation. Passing `None` applies the same policy resolution
295/// as [`resolve_cpu_affinity`].
296///
297/// # Examples
298///
299/// ```rust
300/// use tenferro_cpu::{
301/// resolve_cpu_affinity_with_override, CpuAffinityInput, CpuAffinityPolicy,
302/// CpuAffinitySelectionReason,
303/// };
304/// use tenferro_tensor::CpuDomainId;
305///
306/// let mixed = [
307/// CpuAffinityInput { domain: Some(CpuDomainId::new(1)), logical_bytes: 1 },
308/// CpuAffinityInput { domain: Some(CpuDomainId::new(2)), logical_bytes: 1 },
309/// ];
310/// let selected = resolve_cpu_affinity_with_override(
311/// CpuAffinityPolicy::RequireSingleDomain,
312/// &mixed,
313/// CpuDomainId::new(1),
314/// Some(CpuDomainId::new(9)),
315/// )?;
316/// assert_eq!(selected.domain, CpuDomainId::new(9));
317/// assert_eq!(selected.reason, CpuAffinitySelectionReason::ExplicitOverride);
318/// # Ok::<(), tenferro_cpu::CpuAffinityResolutionError>(())
319/// ```
320///
321/// # Errors
322///
323/// When `explicit_domain` is `None`, returns
324/// [`CpuAffinityResolutionError::LogicalByteCountOverflow`] for an unrepresentable
325/// domain byte total or [`CpuAffinityResolutionError::MultipleKnownDomains`]
326/// when strict policy sees more than one known input domain. A present explicit
327/// override bypasses both policy errors.
328pub fn resolve_cpu_affinity_with_override(
329 policy: CpuAffinityPolicy,
330 inputs: &[CpuAffinityInput],
331 default_domain: CpuDomainId,
332 explicit_domain: Option<CpuDomainId>,
333) -> Result<CpuAffinitySelection, CpuAffinityResolutionError> {
334 if let Some(domain) = explicit_domain {
335 return Ok(CpuAffinitySelection {
336 domain,
337 reason: CpuAffinitySelectionReason::ExplicitOverride,
338 });
339 }
340 match policy {
341 CpuAffinityPolicy::DominantInputBytes => resolve_dominant(inputs, default_domain),
342 CpuAffinityPolicy::RequireSingleDomain => resolve_single(inputs, default_domain),
343 }
344}
345
346fn resolve_dominant(
347 inputs: &[CpuAffinityInput],
348 default_domain: CpuDomainId,
349) -> Result<CpuAffinitySelection, CpuAffinityResolutionError> {
350 let mut totals = DomainTotals::default();
351 for input in inputs {
352 let Some(domain) = input.domain else {
353 continue;
354 };
355 if input.logical_bytes == 0 {
356 continue;
357 }
358 totals.add(domain, input.logical_bytes);
359 }
360
361 if let Some(domain) = totals.smallest_overflowing_domain() {
362 return Err(CpuAffinityResolutionError::LogicalByteCountOverflow { domain });
363 }
364
365 match totals.dominant_domain() {
366 Some(domain) => Ok(CpuAffinitySelection {
367 domain,
368 reason: CpuAffinitySelectionReason::DominantInputBytes,
369 }),
370 None => Ok(default_selection(default_domain)),
371 }
372}
373
374fn resolve_single(
375 inputs: &[CpuAffinityInput],
376 default_domain: CpuDomainId,
377) -> Result<CpuAffinitySelection, CpuAffinityResolutionError> {
378 let mut first = None;
379 let mut second = None;
380 for domain in inputs.iter().filter_map(|input| input.domain) {
381 observe_smallest_two_distinct(domain, &mut first, &mut second);
382 }
383
384 if let (Some(first), Some(second)) = (first, second) {
385 return Err(CpuAffinityResolutionError::MultipleKnownDomains { first, second });
386 }
387
388 Ok(match first {
389 Some(domain) => CpuAffinitySelection {
390 domain,
391 reason: CpuAffinitySelectionReason::SingleInputDomain,
392 },
393 None => default_selection(default_domain),
394 })
395}
396
397fn observe_smallest_two_distinct(
398 domain: CpuDomainId,
399 first: &mut Option<CpuDomainId>,
400 second: &mut Option<CpuDomainId>,
401) {
402 if *first == Some(domain) || *second == Some(domain) {
403 return;
404 }
405 match *first {
406 None => *first = Some(domain),
407 Some(current_first) if domain < current_first => {
408 *second = *first;
409 *first = Some(domain);
410 }
411 Some(_) if second.is_none_or(|current_second| domain < current_second) => {
412 *second = Some(domain);
413 }
414 Some(_) => {}
415 }
416}
417
418fn default_selection(domain: CpuDomainId) -> CpuAffinitySelection {
419 CpuAffinitySelection {
420 domain,
421 reason: CpuAffinitySelectionReason::DefaultDomain,
422 }
423}
424
425#[derive(Clone, Copy, Debug)]
426struct DomainTotal {
427 domain: CpuDomainId,
428 logical_bytes: Option<usize>,
429}
430
431impl DomainTotal {
432 fn new(domain: CpuDomainId, logical_bytes: usize) -> Self {
433 Self {
434 domain,
435 logical_bytes: Some(logical_bytes),
436 }
437 }
438
439 fn add(&mut self, logical_bytes: usize) {
440 self.logical_bytes = self
441 .logical_bytes
442 .and_then(|total| total.checked_add(logical_bytes));
443 }
444}
445
446enum DomainTotals {
447 Inline(SmallVec<[DomainTotal; INLINE_DOMAIN_CAPACITY]>),
448 Heap(BTreeMap<CpuDomainId, Option<usize>>),
449}
450
451impl Default for DomainTotals {
452 fn default() -> Self {
453 Self::Inline(SmallVec::new())
454 }
455}
456
457impl DomainTotals {
458 fn add(&mut self, domain: CpuDomainId, logical_bytes: usize) {
459 let promoted = match self {
460 Self::Inline(entries) => {
461 // INVARIANT: the linear lookup is bounded by the inline capacity;
462 // larger distinct-domain sets are promoted to `BTreeMap` below.
463 if let Some(entry) = entries.iter_mut().find(|entry| entry.domain == domain) {
464 entry.add(logical_bytes);
465 return;
466 }
467 if entries.len() < INLINE_DOMAIN_CAPACITY {
468 entries.push(DomainTotal::new(domain, logical_bytes));
469 return;
470 }
471 let mut heap = BTreeMap::new();
472 for entry in entries.drain(..) {
473 heap.insert(entry.domain, entry.logical_bytes);
474 }
475 heap.insert(domain, Some(logical_bytes));
476 Some(heap)
477 }
478 Self::Heap(entries) => {
479 let total = entries.entry(domain).or_insert(Some(0));
480 *total = total.and_then(|current| current.checked_add(logical_bytes));
481 None
482 }
483 };
484 if let Some(heap) = promoted {
485 *self = Self::Heap(heap);
486 }
487 }
488
489 fn smallest_overflowing_domain(&self) -> Option<CpuDomainId> {
490 match self {
491 Self::Inline(entries) => entries
492 .iter()
493 .filter(|entry| entry.logical_bytes.is_none())
494 .map(|entry| entry.domain)
495 .min(),
496 Self::Heap(entries) => entries
497 .iter()
498 .find_map(|(domain, total)| total.is_none().then_some(*domain)),
499 }
500 }
501
502 fn dominant_domain(&self) -> Option<CpuDomainId> {
503 let mut best = None;
504 match self {
505 Self::Inline(entries) => {
506 for entry in entries {
507 if let Some(logical_bytes) = entry.logical_bytes {
508 consider_dominant(&mut best, entry.domain, logical_bytes);
509 }
510 }
511 }
512 Self::Heap(entries) => {
513 for (&domain, &logical_bytes) in entries {
514 if let Some(logical_bytes) = logical_bytes {
515 consider_dominant(&mut best, domain, logical_bytes);
516 }
517 }
518 }
519 }
520 best.map(|(domain, _)| domain)
521 }
522}
523
524fn consider_dominant(
525 best: &mut Option<(CpuDomainId, usize)>,
526 domain: CpuDomainId,
527 logical_bytes: usize,
528) {
529 let replace = match *best {
530 None => true,
531 Some((best_domain, best_bytes)) => {
532 logical_bytes > best_bytes || (logical_bytes == best_bytes && domain < best_domain)
533 }
534 };
535 if replace {
536 *best = Some((domain, logical_bytes));
537 }
538}
539
540#[cfg(test)]
541mod tests;