paulistrings/engine/partitioned/
topology.rs1use std::fmt;
5use std::io;
6
7use super::PLACEMENT_LOG_TARGET as LOG_TARGET;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct CpuSet(
12 pub Vec<usize>,
14);
15
16impl CpuSet {
17 pub fn parse(s: &str) -> Result<Self, TopologyError> {
23 let invalid = || TopologyError::InvalidCpuList(s.trim().to_string());
24 let index = |token: &str| token.trim().parse::<usize>().map_err(|_| invalid());
25
26 let mut cpus = Vec::new();
27 for token in s.split(',') {
28 let token = token.trim();
29 if token.is_empty() {
30 continue;
31 }
32 match token.split_once('-') {
33 None => cpus.push(index(token)?),
34 Some((lo, hi)) => {
35 let (lo, hi) = (index(lo)?, index(hi)?);
36 if hi < lo {
37 return Err(invalid());
38 }
39 cpus.extend(lo..=hi);
40 }
41 }
42 }
43 if cpus.is_empty() {
44 return Err(TopologyError::EmptySet);
45 }
46 cpus.sort_unstable();
47 cpus.dedup();
48 Ok(Self(cpus))
49 }
50
51 #[must_use]
53 pub fn len(&self) -> usize {
54 self.normalized().len()
55 }
56
57 #[must_use]
59 pub fn is_empty(&self) -> bool {
60 self.0.is_empty()
61 }
62
63 #[must_use]
65 pub fn intersect(&self, other: &Self) -> Self {
66 let other = other.normalized();
67 Self(self.normalized().into_iter().filter(|cpu| other.binary_search(cpu).is_ok()).collect())
68 }
69
70 fn is_subset_of(&self, other: &Self) -> bool {
71 let other = other.normalized();
72 self.0.iter().all(|cpu| other.binary_search(cpu).is_ok())
73 }
74
75 fn normalized(&self) -> Vec<usize> {
76 let mut cpus = self.0.clone();
77 cpus.sort_unstable();
78 cpus.dedup();
79 cpus
80 }
81
82 fn union(&self, other: &Self) -> Self {
83 let mut cpus = self.normalized();
84 cpus.extend(other.normalized());
85 cpus.sort_unstable();
86 cpus.dedup();
87 Self(cpus)
88 }
89}
90
91impl fmt::Display for CpuSet {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 let cpus = self.normalized();
95 let mut first = true;
96 let mut i = 0;
97 while i < cpus.len() {
98 let mut j = i;
99 while j + 1 < cpus.len() && cpus[j + 1] == cpus[j] + 1 {
100 j += 1;
101 }
102 if !first {
103 write!(f, ",")?;
104 }
105 first = false;
106 if i == j {
107 write!(f, "{}", cpus[i])?;
108 } else {
109 write!(f, "{}-{}", cpus[i], cpus[j])?;
110 }
111 i = j + 1;
112 }
113 Ok(())
114 }
115}
116
117#[must_use]
119pub(crate) fn allowed_cpus() -> CpuSet {
120 #[cfg(target_os = "linux")]
121 {
122 unsafe {
124 let mut set: libc::cpu_set_t = std::mem::zeroed();
125 if libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut set) == 0 {
126 let cpus: Vec<usize> = (0..libc::CPU_SETSIZE as usize)
127 .filter(|&cpu| libc::CPU_ISSET(cpu, &set))
128 .collect();
129 if !cpus.is_empty() {
130 return CpuSet(cpus);
131 }
132 }
133 }
134 }
135 CpuSet((0..available_parallelism()).collect())
136}
137
138#[must_use]
142pub fn numa_nodes() -> Vec<(usize, CpuSet)> {
143 let allowed = allowed_cpus();
144 let mut nodes = sysfs_numa_nodes(&allowed);
145 nodes.sort_unstable_by_key(|(id, _)| *id);
146 if nodes.is_empty() {
147 vec![(0, allowed)]
148 } else {
149 nodes
150 }
151}
152
153#[cfg(target_os = "linux")]
154pub(crate) fn sysfs_numa_nodes(allowed: &CpuSet) -> Vec<(usize, CpuSet)> {
155 const ROOT: &str = "/sys/devices/system/node";
156 let Ok(entries) = std::fs::read_dir(ROOT) else {
157 return Vec::new();
158 };
159 let mut nodes = Vec::new();
160 for entry in entries.flatten() {
161 let name = entry.file_name();
162 let Some(id) = name.to_str().and_then(|n| n.strip_prefix("node")) else {
163 continue;
164 };
165 let Ok(id) = id.parse::<usize>() else {
166 continue;
167 };
168 let Ok(list) = std::fs::read_to_string(entry.path().join("cpulist")) else {
169 continue;
170 };
171 let Ok(cpus) = CpuSet::parse(&list) else {
172 continue;
173 };
174 let cpus = cpus.intersect(allowed);
175 if !cpus.is_empty() {
176 nodes.push((id, cpus));
177 }
178 }
179 nodes
180}
181
182#[cfg(not(target_os = "linux"))]
183pub(crate) fn sysfs_numa_nodes(_allowed: &CpuSet) -> Vec<(usize, CpuSet)> {
184 Vec::new()
185}
186
187pub(super) fn pin_current_thread(set: &CpuSet) -> io::Result<()> {
189 #[cfg(target_os = "linux")]
190 {
191 if set.is_empty() {
192 return Err(io::Error::new(
193 io::ErrorKind::InvalidInput,
194 "cannot pin a thread to an empty CPU set",
195 ));
196 }
197 unsafe {
199 let mut mask: libc::cpu_set_t = std::mem::zeroed();
200 for &cpu in &set.0 {
201 if cpu >= libc::CPU_SETSIZE as usize {
202 return Err(io::Error::new(
203 io::ErrorKind::InvalidInput,
204 format!("CPU {cpu} is beyond CPU_SETSIZE"),
205 ));
206 }
207 libc::CPU_SET(cpu, &mut mask);
208 }
209 if libc::sched_setaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mask) != 0 {
210 return Err(io::Error::last_os_error());
211 }
212 }
213 }
214 #[cfg(not(target_os = "linux"))]
215 let _ = set;
216 Ok(())
217}
218
219pub(super) fn bind_current_thread_memory(node: Option<usize>) -> io::Result<()> {
221 #[cfg(target_os = "linux")]
222 {
223 const BITS: usize = 8 * std::mem::size_of::<libc::c_ulong>();
224 let rc = match node {
225 Some(node) => {
226 let words = node / BITS + 1;
227 let mut mask = vec![0 as libc::c_ulong; words];
228 mask[node / BITS] = 1 << (node % BITS);
229 unsafe {
231 libc::syscall(
232 libc::SYS_set_mempolicy,
233 libc::MPOL_BIND,
234 mask.as_ptr(),
235 (words * BITS) as libc::c_ulong,
236 )
237 }
238 }
239 None => unsafe {
241 libc::syscall(
242 libc::SYS_set_mempolicy,
243 libc::MPOL_DEFAULT,
244 std::ptr::null::<libc::c_ulong>(),
245 0 as libc::c_ulong,
246 )
247 },
248 };
249 if rc != 0 {
250 return Err(io::Error::last_os_error());
251 }
252 }
253 #[cfg(not(target_os = "linux"))]
254 let _ = node;
255 Ok(())
256}
257
258#[derive(Clone, Debug)]
260pub(crate) struct PartitionSlot {
261 pub(crate) cpus: Option<CpuSet>,
263 pub(crate) node: Option<usize>,
265 pub(crate) threads: usize,
267 pub(crate) device: Option<u32>,
269}
270
271pub(super) fn build_pool(
273 slot: &PartitionSlot,
274 bind_memory: bool,
275 name: &str,
276) -> Result<rayon::ThreadPool, TopologyError> {
277 let cpus = slot.cpus.clone();
278 let node = slot.node;
279 #[cfg(feature = "cuda")]
280 let device = slot.device;
281 let pool_name = name.to_string();
282 rayon::ThreadPoolBuilder::new()
283 .num_threads(slot.threads)
284 .thread_name(move |index| format!("{pool_name}-{index}"))
285 .spawn_handler(move |thread| {
286 let cpus = cpus.clone();
287 let mut builder = std::thread::Builder::new();
288 if let Some(name) = thread.name() {
289 builder = builder.name(name.to_string());
290 }
291 if let Some(size) = thread.stack_size() {
292 builder = builder.stack_size(size);
293 }
294 builder.spawn(move || {
295 #[cfg(feature = "cuda")]
296 if let Some(device) = device {
297 crate::engine::cuda_context::bind_device_context(device);
298 }
299 if let Some(cpus) = &cpus {
300 if let Err(err) = pin_current_thread(cpus) {
301 log::warn!(target: LOG_TARGET, "failed to pin worker to {cpus}: {err}");
302 }
303 }
304 if bind_memory {
305 if let Err(err) = bind_current_thread_memory(node) {
306 log::warn!(target: LOG_TARGET, "failed to bind worker memory to {node:?}: {err}");
307 }
308 }
309 thread.run();
310 })?;
311 Ok(())
312 })
313 .build()
314 .map_err(|err| TopologyError::Io(io::Error::other(err.to_string())))
315}
316
317#[derive(Clone, Debug, PartialEq, Eq)]
319pub enum Placement {
320 Auto {
325 max_partitions: Option<usize>,
327 },
328 Explicit(
332 Vec<CpuSet>,
334 ),
335 Unpinned {
337 partitions: usize,
339 },
340 #[cfg(feature = "cuda")]
344 Devices {
345 devices: Vec<u32>,
347 per_device: usize,
349 },
350}
351
352#[cfg(feature = "cuda")]
354const DEVICE_PARTITION_THREADS: usize = 4;
355
356#[derive(Clone, Debug, PartialEq, Eq)]
358pub struct PartitionConfig {
359 pub placement: Placement,
361 pub bind_memory: bool,
363 pub threads_per_partition: Option<usize>,
367}
368
369impl Default for PartitionConfig {
370 fn default() -> Self {
372 Self {
373 placement: Placement::Auto { max_partitions: None },
374 bind_memory: true,
375 threads_per_partition: None,
376 }
377 }
378}
379
380impl PartitionConfig {
381 pub(crate) fn resolve(&self) -> Result<Vec<PartitionSlot>, TopologyError> {
383 let mut slots = match &self.placement {
384 Placement::Auto { max_partitions } => resolve_auto(*max_partitions),
385 Placement::Explicit(sets) => resolve_explicit(sets)?,
386 Placement::Unpinned { partitions } => resolve_unpinned(*partitions)?,
387 #[cfg(feature = "cuda")]
388 Placement::Devices { devices, per_device } => resolve_devices(devices, *per_device)?,
389 };
390 if let Some(threads) = self.threads_per_partition {
391 for slot in &mut slots {
392 slot.threads = threads.max(1);
393 }
394 }
395 Ok(slots)
396 }
397
398 pub fn num_partitions(&self) -> Result<usize, TopologyError> {
404 Ok(self.resolve()?.len())
405 }
406}
407
408#[cfg(feature = "cuda")]
409fn resolve_devices(
410 devices: &[u32],
411 per_device: usize,
412) -> Result<Vec<PartitionSlot>, TopologyError> {
413 let count = devices.len() * per_device;
414 if count == 0 || !count.is_power_of_two() {
415 return Err(TopologyError::NotPowerOfTwo(count));
416 }
417 Ok(devices
418 .iter()
419 .flat_map(|&d| {
420 std::iter::repeat_n(
421 PartitionSlot {
422 cpus: None,
423 node: None,
424 threads: DEVICE_PARTITION_THREADS,
425 device: Some(d),
426 },
427 per_device,
428 )
429 })
430 .collect())
431}
432
433fn resolve_auto(max_partitions: Option<usize>) -> Vec<PartitionSlot> {
434 let nodes = numa_nodes();
435 let mut count = prev_power_of_two(nodes.len());
436 if let Some(max) = max_partitions {
437 count = prev_power_of_two(count.min(max));
438 }
439
440 let (base, remainder) = (nodes.len() / count, nodes.len() % count);
442 let mut slots = Vec::with_capacity(count);
443 let mut rest = nodes.as_slice();
444 for partition in 0..count {
445 let take = base + usize::from(partition < remainder);
446 let (group, tail) = rest.split_at(take);
447 rest = tail;
448 let cpus =
449 group.iter().fold(CpuSet(Vec::new()), |accumulator, (_, set)| accumulator.union(set));
450 slots.push(PartitionSlot {
451 node: (group.len() == 1).then(|| group[0].0),
452 threads: cpus.len(),
453 cpus: Some(cpus),
454 device: None,
455 });
456 }
457 slots
458}
459
460fn resolve_explicit(sets: &[CpuSet]) -> Result<Vec<PartitionSlot>, TopologyError> {
461 if !sets.len().is_power_of_two() {
462 return Err(TopologyError::NotPowerOfTwo(sets.len()));
463 }
464 let allowed = allowed_cpus();
465 let nodes = numa_nodes();
466
467 let mut slots = Vec::with_capacity(sets.len());
468 for set in sets {
469 if set.is_empty() {
470 return Err(TopologyError::EmptySet);
471 }
472 let cpus = CpuSet(set.normalized());
473 if let Some(&cpu) = cpus.0.iter().find(|cpu| !allowed.0.contains(cpu)) {
474 return Err(TopologyError::CpuNotAllowed(cpu));
475 }
476 let mut containing =
477 nodes.iter().filter(|(_, node)| cpus.is_subset_of(node)).map(|(id, _)| *id);
478 let node = match (containing.next(), containing.next()) {
479 (Some(id), None) => Some(id),
480 _ => None,
481 };
482 slots.push(PartitionSlot { node, threads: cpus.len(), cpus: Some(cpus), device: None });
483 }
484
485 for (i, slot) in slots.iter().enumerate() {
486 for other in slots.iter().skip(i + 1) {
487 let (a, b) = (slot.cpus.as_ref(), other.cpus.as_ref());
488 if let (Some(a), Some(b)) = (a, b) {
489 let shared = a.intersect(b);
490 if !shared.is_empty() {
491 log::warn!(
492 target: LOG_TARGET,
493 "explicit partitions {a} and {b} overlap on {shared}; \
494 their pools will contend for those CPUs",
495 );
496 }
497 }
498 }
499 }
500 Ok(slots)
501}
502
503fn resolve_unpinned(partitions: usize) -> Result<Vec<PartitionSlot>, TopologyError> {
504 if !partitions.is_power_of_two() {
505 return Err(TopologyError::NotPowerOfTwo(partitions));
506 }
507 let threads = (available_parallelism() / partitions).max(1);
508 Ok(vec![PartitionSlot { cpus: None, node: None, threads, device: None }; partitions])
509}
510
511fn prev_power_of_two(n: usize) -> usize {
513 1 << n.max(1).ilog2()
514}
515
516fn available_parallelism() -> usize {
517 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
518}
519
520#[derive(Debug)]
522pub enum TopologyError {
523 InvalidCpuList(
525 String,
527 ),
528 NotPowerOfTwo(
530 usize,
532 ),
533 EmptySet,
535 Io(
537 io::Error,
539 ),
540 CpuNotAllowed(
542 usize,
544 ),
545}
546
547impl fmt::Display for TopologyError {
548 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
549 match self {
550 Self::InvalidCpuList(list) => {
551 write!(f, "{list:?} is not a cpulist of the form \"0-7,16-23\"")
552 }
553 Self::NotPowerOfTwo(count) => write!(
554 f,
555 "partition count {count} is not a power of two; a partition index is a fixed \
556 set of GF(2) hash rows",
557 ),
558 Self::EmptySet => write!(f, "a partition needs a non-empty CPU set"),
559 Self::Io(err) => write!(f, "topology I/O failed: {err}"),
560 Self::CpuNotAllowed(cpu) => {
561 write!(f, "CPU {cpu} is outside this process's affinity mask",)
562 }
563 }
564 }
565}
566
567impl std::error::Error for TopologyError {
568 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
569 match self {
570 Self::Io(err) => Some(err),
571 _ => None,
572 }
573 }
574}
575
576impl From<io::Error> for TopologyError {
577 fn from(err: io::Error) -> Self {
578 Self::Io(err)
579 }
580}
581
582#[cfg(test)]
583mod tests;