1use std::{
2 collections::HashMap,
3 sync::{Arc, Mutex, Weak},
4};
5
6use laddu_autodiff::AutodiffMode;
7use laddu_data::io::{Partitioning, ReadPlan};
8#[cfg(feature = "wgpu")]
9use laddu_memory::DeviceIdentity;
10use laddu_memory::{
11 MemoryBudget, MemoryDecision, MemoryPlan, MemoryPool, MemoryPoolReport, MemoryReport,
12 MemoryState,
13};
14use rayon::{ThreadPool, ThreadPoolBuilder};
15use serde::{Deserialize, Serialize};
16
17#[cfg(feature = "wgpu")]
18use crate::RuntimeError;
19use crate::{ExecutionError, RuntimeResult};
20
21pub(crate) type NormalizationCache =
22 HashMap<(u64, u64, NormalizationMode), Weak<crate::PreparedNormalization>>;
23
24#[cfg(feature = "mpi")]
25use mpi::{
26 collective::SystemOperation,
27 topology::SimpleCommunicator,
28 traits::{Communicator, CommunicatorCollectives},
29};
30
31#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub enum Precision {
34 #[default]
36 Auto,
37 F32,
39 F64,
41}
42
43#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub enum ThreadPolicy {
46 #[default]
48 Auto,
49 Serial,
51 Fixed(usize),
53}
54
55#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub enum JitPolicy {
58 #[default]
60 Auto,
61 Enabled,
63 Disabled,
65}
66
67#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub enum NormalizationMode {
70 #[default]
72 Auto,
73 General,
75 Verify,
77}
78
79#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
81pub struct CpuOptions {
82 pub threads: ThreadPolicy,
84 pub jit: JitPolicy,
86}
87
88#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
90pub enum GpuBackend {
91 #[default]
93 Auto,
94 Wgpu,
96 Cuda,
98}
99
100#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub enum GpuDeviceSelector {
103 #[default]
105 Auto,
106 Index(usize),
108 PciBusId(String),
110 Name(String),
112}
113
114#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct GpuOptions {
117 pub backend: GpuBackend,
119 pub device: GpuDeviceSelector,
121}
122
123#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
125pub enum Device {
126 #[default]
128 Auto,
129 Cpu(CpuOptions),
131 Gpu(GpuOptions),
133}
134
135#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
137pub struct ExecutionOptions {
138 pub device: Device,
140 pub precision: Precision,
142 pub autodiff: AutodiffMode,
144 #[serde(default)]
146 pub normalization: NormalizationMode,
147 pub partitioning: Partitioning,
149 pub memory: MemoryPlan,
151}
152
153#[derive(Clone)]
155pub struct Execution {
156 requested_device: Device,
157 precision: Precision,
158 autodiff: AutodiffMode,
159 normalization: NormalizationMode,
160 threads: ThreadPolicy,
161 jit: JitPolicy,
162 pool: Option<Arc<ThreadPool>>,
163 partitioning: Partitioning,
164 memory_state: MemoryState,
165 host_memory: MemoryPool,
166 device_memory: Option<MemoryPool>,
167 memory_decisions: Arc<Mutex<Vec<MemoryDecision>>>,
168 normalization_cache: Arc<Mutex<NormalizationCache>>,
169 #[cfg(feature = "wgpu")]
170 wgpu: Option<Arc<laddu_wgpu::WgpuContext>>,
171 #[cfg(feature = "mpi")]
172 communicator: Option<Arc<SimpleCommunicator>>,
173}
174
175impl std::fmt::Debug for Execution {
176 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 #[cfg(feature = "wgpu")]
178 let resolved_device = if self.wgpu.is_some() { "wgpu" } else { "cpu" };
179 #[cfg(not(feature = "wgpu"))]
180 let resolved_device = "cpu";
181 formatter
182 .debug_struct("Execution")
183 .field("requested_device", &self.requested_device)
184 .field("resolved_device", &resolved_device)
185 .field("precision", &self.precision)
186 .field("autodiff", &self.autodiff)
187 .field("normalization", &self.normalization)
188 .field("threads", &self.threads)
189 .field("jit", &self.jit)
190 .field("partitioning", &self.partitioning)
191 .field("host_memory", &self.host_memory.report())
192 .field(
193 "device_memory",
194 &self.device_memory.as_ref().map(MemoryPool::report),
195 )
196 .field("ranks", &self.nranks())
197 .finish_non_exhaustive()
198 }
199}
200
201impl Default for Execution {
202 fn default() -> Self {
203 let memory_state = MemoryState::current();
204 memory_state.refresh();
205 let host_memory = memory_state
206 .pool("host", MemoryBudget::Auto)
207 .expect("host memory discovery must resolve an automatic budget");
208 Self {
209 requested_device: Device::Auto,
210 precision: Precision::F64,
211 autodiff: AutodiffMode::Auto,
212 normalization: NormalizationMode::Auto,
213 threads: ThreadPolicy::Auto,
214 jit: JitPolicy::Auto,
215 pool: None,
216 partitioning: Partitioning::default(),
217 memory_state,
218 host_memory,
219 device_memory: None,
220 memory_decisions: Default::default(),
221 normalization_cache: Default::default(),
222 #[cfg(feature = "wgpu")]
223 wgpu: None,
224 #[cfg(feature = "mpi")]
225 communicator: None,
226 }
227 }
228}
229
230impl Execution {
231 pub fn local(options: ExecutionOptions) -> RuntimeResult<Self> {
239 let memory_state = MemoryState::current();
240 memory_state.refresh();
241 let host_memory = memory_state.pool("host", options.memory.host)?;
242 #[cfg(feature = "wgpu")]
243 let mut wgpu = None;
244 #[cfg(feature = "wgpu")]
245 let mut device_memory = None;
246 #[cfg(not(feature = "wgpu"))]
247 let device_memory = None;
248 let cpu = match &options.device {
249 Device::Auto => CpuOptions::default(),
250 Device::Cpu(options) => options.clone(),
251 Device::Gpu(gpu_options) => {
252 #[cfg(feature = "wgpu")]
253 {
254 if gpu_options.backend == GpuBackend::Cuda {
255 return Err(ExecutionError::GpuUnavailable(gpu_options.backend).into());
256 }
257 let selector = match &gpu_options.device {
258 GpuDeviceSelector::Auto => laddu_wgpu::WgpuDeviceSelector::Auto,
259 GpuDeviceSelector::Index(index) => {
260 laddu_wgpu::WgpuDeviceSelector::Index(*index)
261 }
262 GpuDeviceSelector::PciBusId(id) => {
263 laddu_wgpu::WgpuDeviceSelector::PciBusId(id.clone())
264 }
265 GpuDeviceSelector::Name(name) => {
266 laddu_wgpu::WgpuDeviceSelector::Name(name.clone())
267 }
268 };
269 let precision = match options.precision {
270 Precision::Auto => laddu_wgpu::WgpuPrecision::Auto,
271 Precision::F32 => laddu_wgpu::WgpuPrecision::F32,
272 Precision::F64 => laddu_wgpu::WgpuPrecision::F64,
273 };
274 let mut context = laddu_wgpu::WgpuBackend::default()
275 .open(
276 &laddu_wgpu::WgpuOptions {
277 device: selector,
278 memory_budget: None,
279 },
280 precision,
281 )
282 .map_err(|error| RuntimeError::Wgpu(error.to_string()))?;
283 let resource_id = if context.info().pci_bus_id.is_empty() {
284 format!("wgpu:{}", context.info().index)
285 } else {
286 format!("pci:{}", context.info().pci_bus_id)
287 };
288 let fallback = context
289 .info()
290 .max_buffer_size
291 .min(512 * 1024 * 1024)
292 .max(context.info().max_storage_buffer_binding_size);
293 memory_state.register_discovered_device(
294 resource_id.clone(),
295 context.info().name.clone(),
296 DeviceIdentity {
297 adapter_index: context.info().index,
298 vendor_id: context.info().vendor,
299 device_id: context.info().device,
300 pci_bus_id: context.info().pci_bus_id.clone(),
301 },
302 fallback,
303 );
304 let requested = options.memory.device.unwrap_or(MemoryBudget::Auto);
305 let pool = memory_state.pool(&resource_id, requested)?;
306 context
307 .set_memory_budget(usize::try_from(pool.capacity()).unwrap_or(usize::MAX));
308 device_memory = Some(pool);
309 wgpu = Some(Arc::new(context));
310 CpuOptions::default()
311 }
312 #[cfg(not(feature = "wgpu"))]
313 return Err(ExecutionError::GpuUnavailable(gpu_options.backend).into());
314 }
315 };
316 let precision = match options.precision {
317 Precision::Auto if matches!(options.device, Device::Gpu(_)) => Precision::F32,
318 Precision::Auto => Precision::F64,
319 precision => precision,
320 };
321 #[cfg(not(feature = "jit"))]
322 if cpu.jit == JitPolicy::Enabled {
323 return Err(ExecutionError::JitUnavailable.into());
324 }
325 let pool = match cpu.threads {
326 ThreadPolicy::Fixed(0) => return Err(ExecutionError::ZeroThreads.into()),
327 ThreadPolicy::Fixed(threads) => Some(Arc::new(
328 ThreadPoolBuilder::new()
329 .num_threads(threads)
330 .build()
331 .map_err(|error| ExecutionError::ThreadPool(error.to_string()))?,
332 )),
333 ThreadPolicy::Auto | ThreadPolicy::Serial => None,
334 };
335 Ok(Self {
336 requested_device: options.device,
337 precision,
338 autodiff: options.autodiff,
339 normalization: options.normalization,
340 threads: cpu.threads,
341 jit: cpu.jit,
342 pool,
343 partitioning: options.partitioning,
344 memory_state,
345 host_memory,
346 device_memory,
347 memory_decisions: Default::default(),
348 normalization_cache: Default::default(),
349 #[cfg(feature = "wgpu")]
350 wgpu,
351 #[cfg(feature = "mpi")]
352 communicator: None,
353 })
354 }
355
356 #[cfg(feature = "mpi")]
357 pub fn distributed<C>(options: ExecutionOptions, world: &C) -> RuntimeResult<Self>
363 where
364 C: Communicator,
365 {
366 let local_processes = mpi_local_process_count(world.size());
367 let mut options = options;
368 options.memory.host = shared_mpi_budget(options.memory.host, local_processes);
369 options.memory.device = options
370 .memory
371 .device
372 .map(|budget| shared_mpi_budget(budget, local_processes));
373 let mut execution = Self::local(options)?;
374 execution.record_memory_decision(MemoryDecision {
375 label: "mpi-memory-share".into(),
376 fixed_bytes: 0,
377 bytes_per_event: 0,
378 chunk_events: 0,
379 estimated_peak_bytes: 0,
380 actual_high_water_bytes: None,
381 strategy: format!("equal-share-across-{local_processes}-local-ranks"),
382 });
383 execution.communicator = Some(Arc::new(world.duplicate()));
384 Ok(execution)
385 }
386
387 pub fn requested_device(&self) -> &Device {
389 &self.requested_device
390 }
391
392 #[cfg(feature = "wgpu")]
393 pub(crate) fn wgpu_context(&self) -> Option<&Arc<laddu_wgpu::WgpuContext>> {
394 self.wgpu.as_ref()
395 }
396
397 pub fn precision(&self) -> Precision {
399 self.precision
400 }
401
402 pub fn autodiff_mode(&self) -> AutodiffMode {
404 self.autodiff
405 }
406
407 pub fn normalization_mode(&self) -> NormalizationMode {
409 self.normalization
410 }
411
412 pub(crate) fn normalization_cache(&self) -> &Mutex<NormalizationCache> {
413 &self.normalization_cache
414 }
415
416 pub fn thread_policy(&self) -> ThreadPolicy {
418 self.threads
419 }
420
421 pub fn jit_policy(&self) -> JitPolicy {
423 self.jit
424 }
425
426 pub fn partitioning(&self) -> Partitioning {
428 self.partitioning
429 }
430
431 pub fn memory_state(&self) -> &MemoryState {
433 &self.memory_state
434 }
435
436 pub fn host_memory(&self) -> &MemoryPool {
438 &self.host_memory
439 }
440
441 pub fn device_memory(&self) -> Option<&MemoryPool> {
443 self.device_memory.as_ref()
444 }
445
446 pub fn memory_report(&self) -> MemoryReport {
448 self.memory_state.report()
449 }
450
451 pub fn memory_pool_reports(&self) -> Vec<MemoryPoolReport> {
453 std::iter::once(self.host_memory.report())
454 .chain(self.device_memory.as_ref().map(MemoryPool::report))
455 .collect()
456 }
457
458 pub fn memory_decisions(&self) -> Vec<MemoryDecision> {
460 self.memory_decisions
461 .lock()
462 .unwrap_or_else(|error| error.into_inner())
463 .clone()
464 }
465
466 pub fn record_memory_decision(&self, decision: MemoryDecision) {
468 self.memory_decisions
469 .lock()
470 .unwrap_or_else(|error| error.into_inner())
471 .push(decision);
472 }
473
474 pub fn rank(&self) -> usize {
476 #[cfg(feature = "mpi")]
477 if let Some(communicator) = &self.communicator {
478 return communicator.rank() as usize;
479 }
480 0
481 }
482
483 pub fn nranks(&self) -> usize {
485 #[cfg(feature = "mpi")]
486 if let Some(communicator) = &self.communicator {
487 return communicator.size() as usize;
488 }
489 1
490 }
491
492 pub fn is_distributed(&self) -> bool {
494 self.nranks() > 1
495 }
496
497 #[allow(unused_mut)]
498 pub(crate) fn read_plan(&self, mut plan: ReadPlan) -> ReadPlan {
499 #[cfg(feature = "mpi")]
500 if let Some(communicator) = &self.communicator {
501 plan.distribution = laddu_data::io::Distribution::from_world(communicator.as_ref())
502 .with_partitioning(self.partitioning);
503 }
504 plan
505 }
506
507 pub(crate) fn sum_f64(&self, local: f64) -> f64 {
508 #[cfg(feature = "mpi")]
509 if let Some(communicator) = &self.communicator {
510 let mut global = 0.0;
511 communicator.all_reduce_into(&local, &mut global, SystemOperation::sum());
512 return global;
513 }
514 local
515 }
516
517 pub(crate) fn sum_usize(&self, local: usize) -> usize {
518 #[cfg(feature = "mpi")]
519 if let Some(communicator) = &self.communicator {
520 let local = local as u64;
521 let mut global = 0_u64;
522 communicator.all_reduce_into(&local, &mut global, SystemOperation::sum());
523 return global as usize;
524 }
525 local
526 }
527
528 pub(crate) fn sum_slice(&self, local: &[f64]) -> Vec<f64> {
529 #[cfg(feature = "mpi")]
530 if let Some(communicator) = &self.communicator {
531 let mut global = vec![0.0; local.len()];
532 communicator.all_reduce_into(local, &mut global, SystemOperation::sum());
533 return global;
534 }
535 local.to_vec()
536 }
537
538 pub(crate) fn all_succeeded(&self, local_success: bool) -> bool {
539 self.sum_usize(usize::from(local_success)) == self.nranks()
540 }
541
542 pub(crate) fn is_parallel(&self) -> bool {
543 self.threads != ThreadPolicy::Serial
544 }
545
546 pub(crate) fn install<R: Send>(&self, operation: impl FnOnce() -> R + Send) -> R {
547 match &self.pool {
548 Some(pool) => pool.install(operation),
549 None => operation(),
550 }
551 }
552}
553
554#[cfg(feature = "mpi")]
555fn shared_mpi_budget(budget: MemoryBudget, local_processes: u64) -> MemoryBudget {
556 let divisor = local_processes.max(1);
557 match budget {
558 MemoryBudget::Auto => MemoryBudget::PercentAvailable(0.80 / divisor as f64),
559 MemoryBudget::Bytes(bytes) => MemoryBudget::Bytes((bytes / divisor).max(1)),
560 MemoryBudget::PercentTotal(fraction) => {
561 MemoryBudget::PercentTotal(fraction / divisor as f64)
562 }
563 MemoryBudget::PercentAvailable(fraction) => {
564 MemoryBudget::PercentAvailable(fraction / divisor as f64)
565 }
566 }
567}
568
569#[cfg(feature = "mpi")]
570fn mpi_local_process_count(world_size: i32) -> u64 {
571 const VARIABLES: [&str; 4] = [
575 "OMPI_COMM_WORLD_LOCAL_SIZE",
576 "MPI_LOCALNRANKS",
577 "MV2_COMM_WORLD_LOCAL_SIZE",
578 "SLURM_NTASKS_PER_NODE",
579 ];
580 VARIABLES
581 .iter()
582 .filter_map(|name| std::env::var(name).ok())
583 .find_map(|value| {
584 value
585 .split(|character: char| !character.is_ascii_digit())
586 .find(|part| !part.is_empty())
587 .and_then(|part| part.parse::<u64>().ok())
588 .filter(|count| *count > 0)
589 })
590 .unwrap_or_else(|| u64::try_from(world_size).unwrap_or(1).max(1))
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 #[cfg(not(feature = "wgpu"))]
597 use crate::RuntimeError;
598 use crate::execution::GpuBackend;
599
600 #[test]
601 fn execution_options_roundtrip_through_json() {
602 let options = ExecutionOptions {
603 device: Device::Gpu(GpuOptions {
604 backend: GpuBackend::Wgpu,
605 device: GpuDeviceSelector::PciBusId("0000:01:00.0".into()),
606 }),
607 precision: Precision::F64,
608 autodiff: AutodiffMode::Reverse,
609 normalization: NormalizationMode::Verify,
610 partitioning: Partitioning::FileGroups,
611 memory: MemoryPlan::host_device(
612 MemoryBudget::PercentAvailable(0.5),
613 MemoryBudget::Bytes(1 << 30),
614 ),
615 };
616
617 let json = serde_json::to_string(&options).unwrap();
618 assert_eq!(
619 serde_json::from_str::<ExecutionOptions>(&json).unwrap(),
620 options
621 );
622 }
623
624 #[test]
625 fn execution_selects_nested_cpu_options() {
626 let serial = Execution::local(ExecutionOptions {
627 device: Device::Cpu(CpuOptions {
628 threads: ThreadPolicy::Serial,
629 jit: JitPolicy::Disabled,
630 }),
631 ..ExecutionOptions::default()
632 })
633 .unwrap();
634 assert!(!serial.is_parallel());
635 assert_eq!(serial.jit_policy(), JitPolicy::Disabled);
636 assert_eq!(serial.precision(), Precision::F64);
637
638 let fixed = Execution::local(ExecutionOptions {
639 device: Device::Cpu(CpuOptions {
640 threads: ThreadPolicy::Fixed(2),
641 ..CpuOptions::default()
642 }),
643 ..ExecutionOptions::default()
644 })
645 .unwrap();
646 assert_eq!(fixed.install(rayon::current_num_threads), 2);
647 }
648
649 #[test]
650 fn unavailable_execution_modes_return_capability_errors() {
651 #[cfg(not(feature = "wgpu"))]
652 assert!(matches!(
653 Execution::local(ExecutionOptions {
654 device: Device::Gpu(GpuOptions {
655 backend: GpuBackend::Wgpu,
656 ..GpuOptions::default()
657 }),
658 ..ExecutionOptions::default()
659 }),
660 Err(RuntimeError::Execution(ExecutionError::GpuUnavailable(
661 GpuBackend::Wgpu
662 )))
663 ));
664 #[cfg(feature = "wgpu")]
665 assert!(
666 Execution::local(ExecutionOptions {
667 device: Device::Gpu(GpuOptions {
668 backend: GpuBackend::Wgpu,
669 ..GpuOptions::default()
670 }),
671 ..ExecutionOptions::default()
672 })
673 .is_ok()
674 );
675 let f32 = Execution::local(ExecutionOptions {
676 device: Device::Cpu(CpuOptions::default()),
677 precision: Precision::F32,
678 ..ExecutionOptions::default()
679 })
680 .unwrap();
681 assert_eq!(f32.precision(), Precision::F32);
682
683 let reverse = Execution::local(ExecutionOptions {
684 autodiff: AutodiffMode::Reverse,
685 ..ExecutionOptions::default()
686 })
687 .unwrap();
688 assert_eq!(reverse.autodiff_mode(), AutodiffMode::Reverse);
689
690 let reverse_f32 = Execution::local(ExecutionOptions {
691 precision: Precision::F32,
692 autodiff: AutodiffMode::Reverse,
693 ..ExecutionOptions::default()
694 })
695 .unwrap();
696 assert_eq!(reverse_f32.precision(), Precision::F32);
697 assert_eq!(reverse_f32.autodiff_mode(), AutodiffMode::Reverse);
698 }
699}