Skip to main content

laddu_runtime/
execution.rs

1use std::sync::{Arc, Mutex};
2
3use laddu_autodiff::AutodiffMode;
4use laddu_data::io::{Partitioning, ReadPlan};
5#[cfg(feature = "wgpu")]
6use laddu_memory::{DeviceIdentity, MemoryResource};
7use laddu_memory::{
8    MemoryBudget, MemoryDecision, MemoryPlan, MemoryPool, MemoryPoolReport, MemoryReport,
9    MemoryState,
10};
11use rayon::{ThreadPool, ThreadPoolBuilder};
12use serde::{Deserialize, Serialize};
13
14#[cfg(feature = "wgpu")]
15use crate::RuntimeError;
16use crate::{ExecutionError, RuntimeResult};
17
18#[cfg(feature = "mpi")]
19use mpi::{
20    collective::SystemOperation,
21    topology::SimpleCommunicator,
22    traits::{Communicator, CommunicatorCollectives},
23};
24
25/// Numeric precision used to execute a model.
26#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub enum Precision {
28    /// Select a precision appropriate for the resolved device.
29    #[default]
30    Auto,
31    /// Use 32-bit floating-point arithmetic.
32    F32,
33    /// Use 64-bit floating-point arithmetic.
34    F64,
35}
36
37/// Policy controlling CPU worker-thread use.
38#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
39pub enum ThreadPolicy {
40    /// Use the runtime's default parallelism.
41    #[default]
42    Auto,
43    /// Execute serially on the calling thread.
44    Serial,
45    /// Use exactly the given number of worker threads.
46    Fixed(usize),
47}
48
49/// Policy controlling CPU just-in-time compilation.
50#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub enum JitPolicy {
52    /// Use JIT compilation when it is available and applicable.
53    #[default]
54    Auto,
55    /// Require the JIT-capable execution path.
56    Enabled,
57    /// Always use the interpreter.
58    Disabled,
59}
60
61/// CPU-specific execution options.
62#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
63pub struct CpuOptions {
64    /// Worker-thread policy.
65    pub threads: ThreadPolicy,
66    /// JIT compilation policy.
67    pub jit: JitPolicy,
68}
69
70/// GPU implementation to use.
71#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
72pub enum GpuBackend {
73    /// Select an available GPU backend automatically.
74    #[default]
75    Auto,
76    /// Use the WebGPU backend.
77    Wgpu,
78    /// Use the CUDA backend.
79    Cuda,
80}
81
82/// Rule used to select a GPU device.
83#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
84pub enum GpuDeviceSelector {
85    /// Select a suitable device automatically.
86    #[default]
87    Auto,
88    /// Select the adapter at the given enumeration index.
89    Index(usize),
90    /// Select the adapter with the given PCI bus identifier.
91    PciBusId(String),
92    /// Select an adapter whose name matches the given string.
93    Name(String),
94}
95
96/// GPU-specific execution options.
97#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct GpuOptions {
99    /// GPU backend to use.
100    pub backend: GpuBackend,
101    /// GPU device selection rule.
102    pub device: GpuDeviceSelector,
103}
104
105/// Device on which a model should execute.
106#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
107pub enum Device {
108    /// Select a suitable device automatically.
109    #[default]
110    Auto,
111    /// Execute on a CPU with the supplied options.
112    Cpu(CpuOptions),
113    /// Execute on a GPU with the supplied options.
114    Gpu(GpuOptions),
115}
116
117/// Options used to construct an [`Execution`] context.
118#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
119pub struct ExecutionOptions {
120    /// Requested execution device.
121    pub device: Device,
122    /// Requested numeric precision.
123    pub precision: Precision,
124    /// Automatic-differentiation strategy.
125    pub autodiff: AutodiffMode,
126    /// Dataset partitioning strategy for distributed execution.
127    pub partitioning: Partitioning,
128    /// Host and accelerator memory budgets.
129    pub memory: MemoryPlan,
130}
131
132/// Resolved resources and policies used to execute models.
133#[derive(Clone)]
134pub struct Execution {
135    requested_device: Device,
136    precision: Precision,
137    autodiff: AutodiffMode,
138    threads: ThreadPolicy,
139    jit: JitPolicy,
140    pool: Option<Arc<ThreadPool>>,
141    partitioning: Partitioning,
142    memory_state: MemoryState,
143    host_memory: MemoryPool,
144    device_memory: Option<MemoryPool>,
145    memory_decisions: Arc<Mutex<Vec<MemoryDecision>>>,
146    #[cfg(feature = "wgpu")]
147    wgpu: Option<Arc<laddu_wgpu::WgpuContext>>,
148    #[cfg(feature = "mpi")]
149    communicator: Option<Arc<SimpleCommunicator>>,
150}
151
152impl std::fmt::Debug for Execution {
153    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        #[cfg(feature = "wgpu")]
155        let resolved_device = if self.wgpu.is_some() { "wgpu" } else { "cpu" };
156        #[cfg(not(feature = "wgpu"))]
157        let resolved_device = "cpu";
158        formatter
159            .debug_struct("Execution")
160            .field("requested_device", &self.requested_device)
161            .field("resolved_device", &resolved_device)
162            .field("precision", &self.precision)
163            .field("autodiff", &self.autodiff)
164            .field("threads", &self.threads)
165            .field("jit", &self.jit)
166            .field("partitioning", &self.partitioning)
167            .field("host_memory", &self.host_memory.report())
168            .field(
169                "device_memory",
170                &self.device_memory.as_ref().map(MemoryPool::report),
171            )
172            .field("ranks", &self.nranks())
173            .finish_non_exhaustive()
174    }
175}
176
177impl Default for Execution {
178    fn default() -> Self {
179        let memory_state = MemoryState::current();
180        memory_state.refresh();
181        let host_memory = memory_state
182            .pool("host", MemoryBudget::Auto)
183            .expect("host memory discovery must resolve an automatic budget");
184        Self {
185            requested_device: Device::Auto,
186            precision: Precision::F64,
187            autodiff: AutodiffMode::Forward,
188            threads: ThreadPolicy::Auto,
189            jit: JitPolicy::Auto,
190            pool: None,
191            partitioning: Partitioning::default(),
192            memory_state,
193            host_memory,
194            device_memory: None,
195            memory_decisions: Default::default(),
196            #[cfg(feature = "wgpu")]
197            wgpu: None,
198            #[cfg(feature = "mpi")]
199            communicator: None,
200        }
201    }
202}
203
204impl Execution {
205    /// Creates a non-distributed execution context from `options`.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`RuntimeError`] when the requested backend or precision is
210    /// unavailable, GPU initialization fails, or the CPU thread pool cannot be
211    /// created.
212    pub fn local(options: ExecutionOptions) -> RuntimeResult<Self> {
213        let memory_state = MemoryState::current();
214        memory_state.refresh();
215        let host_memory = memory_state.pool("host", options.memory.host)?;
216        #[cfg(feature = "wgpu")]
217        let mut wgpu = None;
218        #[cfg(feature = "wgpu")]
219        let mut device_memory = None;
220        #[cfg(not(feature = "wgpu"))]
221        let device_memory = None;
222        let cpu = match &options.device {
223            Device::Auto => CpuOptions::default(),
224            Device::Cpu(options) => options.clone(),
225            Device::Gpu(gpu_options) => {
226                #[cfg(feature = "wgpu")]
227                {
228                    if gpu_options.backend == GpuBackend::Cuda {
229                        return Err(ExecutionError::GpuUnavailable(gpu_options.backend).into());
230                    }
231                    let selector = match &gpu_options.device {
232                        GpuDeviceSelector::Auto => laddu_wgpu::WgpuDeviceSelector::Auto,
233                        GpuDeviceSelector::Index(index) => {
234                            laddu_wgpu::WgpuDeviceSelector::Index(*index)
235                        }
236                        GpuDeviceSelector::PciBusId(id) => {
237                            laddu_wgpu::WgpuDeviceSelector::PciBusId(id.clone())
238                        }
239                        GpuDeviceSelector::Name(name) => {
240                            laddu_wgpu::WgpuDeviceSelector::Name(name.clone())
241                        }
242                    };
243                    let precision = match options.precision {
244                        Precision::Auto => laddu_wgpu::WgpuPrecision::Auto,
245                        Precision::F32 => laddu_wgpu::WgpuPrecision::F32,
246                        Precision::F64 => laddu_wgpu::WgpuPrecision::F64,
247                    };
248                    let mut context = laddu_wgpu::WgpuBackend::default()
249                        .open(
250                            &laddu_wgpu::WgpuOptions {
251                                device: selector,
252                                memory_budget: None,
253                            },
254                            precision,
255                        )
256                        .map_err(|error| RuntimeError::Wgpu(error.to_string()))?;
257                    let resource_id = if context.info().pci_bus_id.is_empty() {
258                        format!("wgpu:{}", context.info().index)
259                    } else {
260                        format!("pci:{}", context.info().pci_bus_id)
261                    };
262                    let fallback = context
263                        .info()
264                        .max_buffer_size
265                        .min(512 * 1024 * 1024)
266                        .max(context.info().max_storage_buffer_binding_size);
267                    let resource = MemoryResource::discover_device(
268                        resource_id.clone(),
269                        context.info().name.clone(),
270                        DeviceIdentity {
271                            adapter_index: context.info().index,
272                            vendor_id: context.info().vendor,
273                            device_id: context.info().device,
274                            pci_bus_id: context.info().pci_bus_id.clone(),
275                        },
276                        fallback,
277                    );
278                    memory_state.register_device(resource);
279                    let requested = options.memory.device.unwrap_or(MemoryBudget::Auto);
280                    let pool = memory_state.pool(&resource_id, requested)?;
281                    context
282                        .set_memory_budget(usize::try_from(pool.capacity()).unwrap_or(usize::MAX));
283                    device_memory = Some(pool);
284                    wgpu = Some(Arc::new(context));
285                    CpuOptions::default()
286                }
287                #[cfg(not(feature = "wgpu"))]
288                return Err(ExecutionError::GpuUnavailable(gpu_options.backend).into());
289            }
290        };
291        let precision = match options.precision {
292            Precision::Auto if matches!(options.device, Device::Gpu(_)) => Precision::F32,
293            Precision::Auto => Precision::F64,
294            precision => precision,
295        };
296        #[cfg(not(feature = "jit"))]
297        if cpu.jit == JitPolicy::Enabled {
298            return Err(ExecutionError::JitUnavailable.into());
299        }
300        let pool = match cpu.threads {
301            ThreadPolicy::Fixed(0) => return Err(ExecutionError::ZeroThreads.into()),
302            ThreadPolicy::Fixed(threads) => Some(Arc::new(
303                ThreadPoolBuilder::new()
304                    .num_threads(threads)
305                    .build()
306                    .map_err(|error| ExecutionError::ThreadPool(error.to_string()))?,
307            )),
308            ThreadPolicy::Auto | ThreadPolicy::Serial => None,
309        };
310        Ok(Self {
311            requested_device: options.device,
312            precision,
313            autodiff: options.autodiff,
314            threads: cpu.threads,
315            jit: cpu.jit,
316            pool,
317            partitioning: options.partitioning,
318            memory_state,
319            host_memory,
320            device_memory,
321            memory_decisions: Default::default(),
322            #[cfg(feature = "wgpu")]
323            wgpu,
324            #[cfg(feature = "mpi")]
325            communicator: None,
326        })
327    }
328
329    #[cfg(feature = "mpi")]
330    /// Creates a distributed execution context over the supplied MPI communicator.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`RuntimeError`] when local execution initialization fails.
335    pub fn distributed<C>(options: ExecutionOptions, world: &C) -> RuntimeResult<Self>
336    where
337        C: Communicator,
338    {
339        let local_processes = mpi_local_process_count(world.size());
340        let mut options = options;
341        options.memory.host = shared_mpi_budget(options.memory.host, local_processes);
342        options.memory.device = options
343            .memory
344            .device
345            .map(|budget| shared_mpi_budget(budget, local_processes));
346        let mut execution = Self::local(options)?;
347        execution.record_memory_decision(MemoryDecision {
348            label: "mpi-memory-share".into(),
349            fixed_bytes: 0,
350            bytes_per_event: 0,
351            chunk_events: 0,
352            estimated_peak_bytes: 0,
353            actual_high_water_bytes: None,
354            strategy: format!("equal-share-across-{local_processes}-local-ranks"),
355        });
356        execution.communicator = Some(Arc::new(world.duplicate()));
357        Ok(execution)
358    }
359
360    /// Returns the device requested when this context was created.
361    pub fn requested_device(&self) -> &Device {
362        &self.requested_device
363    }
364
365    #[cfg(feature = "wgpu")]
366    pub(crate) fn wgpu_context(&self) -> Option<&Arc<laddu_wgpu::WgpuContext>> {
367        self.wgpu.as_ref()
368    }
369
370    /// Returns the resolved numeric precision.
371    pub fn precision(&self) -> Precision {
372        self.precision
373    }
374
375    /// Returns the automatic-differentiation strategy.
376    pub fn autodiff_mode(&self) -> AutodiffMode {
377        self.autodiff
378    }
379
380    /// Returns the CPU worker-thread policy.
381    pub fn thread_policy(&self) -> ThreadPolicy {
382        self.threads
383    }
384
385    /// Returns the CPU JIT policy.
386    pub fn jit_policy(&self) -> JitPolicy {
387        self.jit
388    }
389
390    /// Returns the distributed dataset-partitioning strategy.
391    pub fn partitioning(&self) -> Partitioning {
392        self.partitioning
393    }
394
395    /// Returns the live memory state shared by this execution.
396    pub fn memory_state(&self) -> &MemoryState {
397        &self.memory_state
398    }
399
400    /// Returns the resolved host-memory pool.
401    pub fn host_memory(&self) -> &MemoryPool {
402        &self.host_memory
403    }
404
405    /// Returns the resolved accelerator-memory pool, if any.
406    pub fn device_memory(&self) -> Option<&MemoryPool> {
407        self.device_memory.as_ref()
408    }
409
410    /// Returns current physical-resource memory information.
411    pub fn memory_report(&self) -> MemoryReport {
412        self.memory_state.report()
413    }
414
415    /// Returns the resolved execution-pool reports.
416    pub fn memory_pool_reports(&self) -> Vec<MemoryPoolReport> {
417        std::iter::once(self.host_memory.report())
418            .chain(self.device_memory.as_ref().map(MemoryPool::report))
419            .collect()
420    }
421
422    /// Returns memory-derived decisions recorded by this execution.
423    pub fn memory_decisions(&self) -> Vec<MemoryDecision> {
424        self.memory_decisions
425            .lock()
426            .unwrap_or_else(|error| error.into_inner())
427            .clone()
428    }
429
430    /// Records a memory-planning decision for later diagnostics.
431    pub fn record_memory_decision(&self, decision: MemoryDecision) {
432        self.memory_decisions
433            .lock()
434            .unwrap_or_else(|error| error.into_inner())
435            .push(decision);
436    }
437
438    /// Returns the zero-based rank of this process.
439    pub fn rank(&self) -> usize {
440        #[cfg(feature = "mpi")]
441        if let Some(communicator) = &self.communicator {
442            return communicator.rank() as usize;
443        }
444        0
445    }
446
447    /// Returns the number of participating ranks.
448    pub fn nranks(&self) -> usize {
449        #[cfg(feature = "mpi")]
450        if let Some(communicator) = &self.communicator {
451            return communicator.size() as usize;
452        }
453        1
454    }
455
456    /// Returns whether this context spans more than one rank.
457    pub fn is_distributed(&self) -> bool {
458        self.nranks() > 1
459    }
460
461    #[allow(unused_mut)]
462    pub(crate) fn read_plan(&self, mut plan: ReadPlan) -> ReadPlan {
463        #[cfg(feature = "mpi")]
464        if let Some(communicator) = &self.communicator {
465            plan.distribution = laddu_data::io::Distribution::from_world(communicator.as_ref())
466                .with_partitioning(self.partitioning);
467        }
468        plan
469    }
470
471    pub(crate) fn sum_f64(&self, local: f64) -> f64 {
472        #[cfg(feature = "mpi")]
473        if let Some(communicator) = &self.communicator {
474            let mut global = 0.0;
475            communicator.all_reduce_into(&local, &mut global, SystemOperation::sum());
476            return global;
477        }
478        local
479    }
480
481    pub(crate) fn sum_usize(&self, local: usize) -> usize {
482        #[cfg(feature = "mpi")]
483        if let Some(communicator) = &self.communicator {
484            let local = local as u64;
485            let mut global = 0_u64;
486            communicator.all_reduce_into(&local, &mut global, SystemOperation::sum());
487            return global as usize;
488        }
489        local
490    }
491
492    pub(crate) fn sum_slice(&self, local: &[f64]) -> Vec<f64> {
493        #[cfg(feature = "mpi")]
494        if let Some(communicator) = &self.communicator {
495            let mut global = vec![0.0; local.len()];
496            communicator.all_reduce_into(local, &mut global, SystemOperation::sum());
497            return global;
498        }
499        local.to_vec()
500    }
501
502    pub(crate) fn all_succeeded(&self, local_success: bool) -> bool {
503        self.sum_usize(usize::from(local_success)) == self.nranks()
504    }
505
506    pub(crate) fn is_parallel(&self) -> bool {
507        self.threads != ThreadPolicy::Serial
508    }
509
510    pub(crate) fn install<R: Send>(&self, operation: impl FnOnce() -> R + Send) -> R {
511        match &self.pool {
512            Some(pool) => pool.install(operation),
513            None => operation(),
514        }
515    }
516}
517
518#[cfg(feature = "mpi")]
519fn shared_mpi_budget(budget: MemoryBudget, local_processes: u64) -> MemoryBudget {
520    let divisor = local_processes.max(1);
521    match budget {
522        MemoryBudget::Auto => MemoryBudget::PercentAvailable(0.80 / divisor as f64),
523        MemoryBudget::Bytes(bytes) => MemoryBudget::Bytes((bytes / divisor).max(1)),
524        MemoryBudget::PercentTotal(fraction) => {
525            MemoryBudget::PercentTotal(fraction / divisor as f64)
526        }
527        MemoryBudget::PercentAvailable(fraction) => {
528            MemoryBudget::PercentAvailable(fraction / divisor as f64)
529        }
530    }
531}
532
533#[cfg(feature = "mpi")]
534fn mpi_local_process_count(world_size: i32) -> u64 {
535    // Common launchers expose node-local process counts. Falling back to the
536    // world size is conservative on multi-node jobs and prevents accidental
537    // host/device overcommit when launcher metadata is unavailable.
538    const VARIABLES: [&str; 4] = [
539        "OMPI_COMM_WORLD_LOCAL_SIZE",
540        "MPI_LOCALNRANKS",
541        "MV2_COMM_WORLD_LOCAL_SIZE",
542        "SLURM_NTASKS_PER_NODE",
543    ];
544    VARIABLES
545        .iter()
546        .filter_map(|name| std::env::var(name).ok())
547        .find_map(|value| {
548            value
549                .split(|character: char| !character.is_ascii_digit())
550                .find(|part| !part.is_empty())
551                .and_then(|part| part.parse::<u64>().ok())
552                .filter(|count| *count > 0)
553        })
554        .unwrap_or_else(|| u64::try_from(world_size).unwrap_or(1).max(1))
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    #[cfg(not(feature = "wgpu"))]
561    use crate::RuntimeError;
562    use crate::execution::GpuBackend;
563
564    #[test]
565    fn execution_options_roundtrip_through_json() {
566        let options = ExecutionOptions {
567            device: Device::Gpu(GpuOptions {
568                backend: GpuBackend::Wgpu,
569                device: GpuDeviceSelector::PciBusId("0000:01:00.0".into()),
570            }),
571            precision: Precision::F64,
572            autodiff: AutodiffMode::Reverse,
573            partitioning: Partitioning::FileGroups,
574            memory: MemoryPlan::host_device(
575                MemoryBudget::PercentAvailable(0.5),
576                MemoryBudget::Bytes(1 << 30),
577            ),
578        };
579
580        let json = serde_json::to_string(&options).unwrap();
581        assert_eq!(
582            serde_json::from_str::<ExecutionOptions>(&json).unwrap(),
583            options
584        );
585    }
586
587    #[test]
588    fn execution_selects_nested_cpu_options() {
589        let serial = Execution::local(ExecutionOptions {
590            device: Device::Cpu(CpuOptions {
591                threads: ThreadPolicy::Serial,
592                jit: JitPolicy::Disabled,
593            }),
594            ..ExecutionOptions::default()
595        })
596        .unwrap();
597        assert!(!serial.is_parallel());
598        assert_eq!(serial.jit_policy(), JitPolicy::Disabled);
599        assert_eq!(serial.precision(), Precision::F64);
600
601        let fixed = Execution::local(ExecutionOptions {
602            device: Device::Cpu(CpuOptions {
603                threads: ThreadPolicy::Fixed(2),
604                ..CpuOptions::default()
605            }),
606            ..ExecutionOptions::default()
607        })
608        .unwrap();
609        assert_eq!(fixed.install(rayon::current_num_threads), 2);
610    }
611
612    #[test]
613    fn unavailable_execution_modes_return_capability_errors() {
614        #[cfg(not(feature = "wgpu"))]
615        assert!(matches!(
616            Execution::local(ExecutionOptions {
617                device: Device::Gpu(GpuOptions {
618                    backend: GpuBackend::Wgpu,
619                    ..GpuOptions::default()
620                }),
621                ..ExecutionOptions::default()
622            }),
623            Err(RuntimeError::Execution(ExecutionError::GpuUnavailable(
624                GpuBackend::Wgpu
625            )))
626        ));
627        #[cfg(feature = "wgpu")]
628        assert!(
629            Execution::local(ExecutionOptions {
630                device: Device::Gpu(GpuOptions {
631                    backend: GpuBackend::Wgpu,
632                    ..GpuOptions::default()
633                }),
634                ..ExecutionOptions::default()
635            })
636            .is_ok()
637        );
638        let f32 = Execution::local(ExecutionOptions {
639            device: Device::Cpu(CpuOptions::default()),
640            precision: Precision::F32,
641            ..ExecutionOptions::default()
642        })
643        .unwrap();
644        assert_eq!(f32.precision(), Precision::F32);
645
646        let reverse = Execution::local(ExecutionOptions {
647            autodiff: AutodiffMode::Reverse,
648            ..ExecutionOptions::default()
649        })
650        .unwrap();
651        assert_eq!(reverse.autodiff_mode(), AutodiffMode::Reverse);
652
653        let reverse_f32 = Execution::local(ExecutionOptions {
654            precision: Precision::F32,
655            autodiff: AutodiffMode::Reverse,
656            ..ExecutionOptions::default()
657        })
658        .unwrap();
659        assert_eq!(reverse_f32.precision(), Precision::F32);
660        assert_eq!(reverse_f32.autodiff_mode(), AutodiffMode::Reverse);
661    }
662}