Skip to main content

laddu_runtime/
execution.rs

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/// Numeric precision used to execute a model.
32#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub enum Precision {
34    /// Select a precision appropriate for the resolved device.
35    #[default]
36    Auto,
37    /// Use 32-bit floating-point arithmetic.
38    F32,
39    /// Use 64-bit floating-point arithmetic.
40    F64,
41}
42
43/// Policy controlling CPU worker-thread use.
44#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub enum ThreadPolicy {
46    /// Use the runtime's default parallelism.
47    #[default]
48    Auto,
49    /// Execute serially on the calling thread.
50    Serial,
51    /// Use exactly the given number of worker threads.
52    Fixed(usize),
53}
54
55/// Policy controlling CPU just-in-time compilation.
56#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub enum JitPolicy {
58    /// Use JIT compilation when it is available and applicable.
59    #[default]
60    Auto,
61    /// Require the JIT-capable execution path.
62    Enabled,
63    /// Always use the interpreter.
64    Disabled,
65}
66
67/// Policy controlling compiler-native accepted normalization.
68#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub enum NormalizationMode {
70    /// Select exact sufficient statistics when cost and memory estimates are favorable.
71    #[default]
72    Auto,
73    /// Always use the ordinary accepted-event reduction path.
74    General,
75    /// Evaluate both selected and general paths and reject disagreements.
76    Verify,
77}
78
79/// CPU-specific execution options.
80#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
81pub struct CpuOptions {
82    /// Worker-thread policy.
83    pub threads: ThreadPolicy,
84    /// JIT compilation policy.
85    pub jit: JitPolicy,
86}
87
88/// GPU implementation to use.
89#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
90pub enum GpuBackend {
91    /// Select an available GPU backend automatically.
92    #[default]
93    Auto,
94    /// Use the WebGPU backend.
95    Wgpu,
96    /// Use the CUDA backend.
97    Cuda,
98}
99
100/// Rule used to select a GPU device.
101#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub enum GpuDeviceSelector {
103    /// Select a suitable device automatically.
104    #[default]
105    Auto,
106    /// Select the adapter at the given enumeration index.
107    Index(usize),
108    /// Select the adapter with the given PCI bus identifier.
109    PciBusId(String),
110    /// Select an adapter whose name matches the given string.
111    Name(String),
112}
113
114/// GPU-specific execution options.
115#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct GpuOptions {
117    /// GPU backend to use.
118    pub backend: GpuBackend,
119    /// GPU device selection rule.
120    pub device: GpuDeviceSelector,
121}
122
123/// Device on which a model should execute.
124#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
125pub enum Device {
126    /// Select a suitable device automatically.
127    #[default]
128    Auto,
129    /// Execute on a CPU with the supplied options.
130    Cpu(CpuOptions),
131    /// Execute on a GPU with the supplied options.
132    Gpu(GpuOptions),
133}
134
135/// Options used to construct an [`Execution`] context.
136#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
137pub struct ExecutionOptions {
138    /// Requested execution device.
139    pub device: Device,
140    /// Requested numeric precision.
141    pub precision: Precision,
142    /// Automatic-differentiation strategy.
143    pub autodiff: AutodiffMode,
144    /// Accepted-normalization selection and verification policy.
145    #[serde(default)]
146    pub normalization: NormalizationMode,
147    /// Dataset partitioning strategy for distributed execution.
148    pub partitioning: Partitioning,
149    /// Host and accelerator memory budgets.
150    pub memory: MemoryPlan,
151}
152
153/// Resolved resources and policies used to execute models.
154#[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
201struct ResolvedCpu {
202    threads: ThreadPolicy,
203    jit: JitPolicy,
204    pool: Option<Arc<ThreadPool>>,
205}
206
207struct ResolvedHost {
208    state: MemoryState,
209    pool: MemoryPool,
210}
211
212fn resolve_host_memory(budget: MemoryBudget) -> RuntimeResult<ResolvedHost> {
213    let state = MemoryState::current();
214    state.refresh();
215    let pool = state.pool("host", budget)?;
216    Ok(ResolvedHost { state, pool })
217}
218
219fn resolve_precision(requested: Precision, gpu_requested: bool) -> Precision {
220    match requested {
221        Precision::Auto if gpu_requested => Precision::F32,
222        Precision::Auto => Precision::F64,
223        precision => precision,
224    }
225}
226
227fn resolve_cpu(options: CpuOptions) -> RuntimeResult<ResolvedCpu> {
228    #[cfg(not(feature = "jit"))]
229    if options.jit == JitPolicy::Enabled {
230        return Err(ExecutionError::JitUnavailable.into());
231    }
232    let pool = match options.threads {
233        ThreadPolicy::Fixed(0) => return Err(ExecutionError::ZeroThreads.into()),
234        ThreadPolicy::Fixed(threads) => Some(Arc::new(
235            ThreadPoolBuilder::new()
236                .num_threads(threads)
237                .build()
238                .map_err(|error| ExecutionError::ThreadPool(error.to_string()))?,
239        )),
240        ThreadPolicy::Auto | ThreadPolicy::Serial => None,
241    };
242    Ok(ResolvedCpu {
243        threads: options.threads,
244        jit: options.jit,
245        pool,
246    })
247}
248
249#[cfg(feature = "wgpu")]
250struct ResolvedGpu {
251    context: Arc<laddu_wgpu::WgpuContext>,
252    memory: MemoryPool,
253}
254
255#[cfg(feature = "wgpu")]
256fn resolve_gpu(
257    memory_state: &MemoryState,
258    options: &GpuOptions,
259    requested_precision: Precision,
260    requested_memory: Option<MemoryBudget>,
261) -> RuntimeResult<ResolvedGpu> {
262    if options.backend == GpuBackend::Cuda {
263        return Err(ExecutionError::GpuUnavailable(options.backend).into());
264    }
265    let selector = match &options.device {
266        GpuDeviceSelector::Auto => laddu_wgpu::WgpuDeviceSelector::Auto,
267        GpuDeviceSelector::Index(index) => laddu_wgpu::WgpuDeviceSelector::Index(*index),
268        GpuDeviceSelector::PciBusId(id) => laddu_wgpu::WgpuDeviceSelector::PciBusId(id.clone()),
269        GpuDeviceSelector::Name(name) => laddu_wgpu::WgpuDeviceSelector::Name(name.clone()),
270    };
271    let precision = match requested_precision {
272        Precision::Auto => laddu_wgpu::WgpuPrecision::Auto,
273        Precision::F32 => laddu_wgpu::WgpuPrecision::F32,
274        Precision::F64 => laddu_wgpu::WgpuPrecision::F64,
275    };
276    let mut context = laddu_wgpu::WgpuBackend::default()
277        .open(
278            &laddu_wgpu::WgpuOptions {
279                device: selector,
280                memory_budget: None,
281            },
282            precision,
283        )
284        .map_err(|error| RuntimeError::Wgpu(error.to_string()))?;
285    let resource_id = if context.info().pci_bus_id.is_empty() {
286        format!("wgpu:{}", context.info().index)
287    } else {
288        format!("pci:{}", context.info().pci_bus_id)
289    };
290    let fallback = context
291        .info()
292        .max_buffer_size
293        .min(512 * 1024 * 1024)
294        .max(context.info().max_storage_buffer_binding_size);
295    memory_state.register_discovered_device(
296        resource_id.clone(),
297        context.info().name.clone(),
298        DeviceIdentity {
299            adapter_index: context.info().index,
300            vendor_id: context.info().vendor,
301            device_id: context.info().device,
302            pci_bus_id: context.info().pci_bus_id.clone(),
303        },
304        fallback,
305    );
306    let memory = memory_state.pool(&resource_id, requested_memory.unwrap_or(MemoryBudget::Auto))?;
307    context.set_memory_budget(usize::try_from(memory.capacity()).unwrap_or(usize::MAX));
308    Ok(ResolvedGpu {
309        context: Arc::new(context),
310        memory,
311    })
312}
313
314impl Default for Execution {
315    fn default() -> Self {
316        let resolved_host = resolve_host_memory(MemoryBudget::Auto)
317            .expect("host memory discovery must resolve an automatic budget");
318        Self {
319            requested_device: Device::Auto,
320            precision: Precision::F64,
321            autodiff: AutodiffMode::Auto,
322            normalization: NormalizationMode::Auto,
323            threads: ThreadPolicy::Auto,
324            jit: JitPolicy::Auto,
325            pool: None,
326            partitioning: Partitioning::default(),
327            memory_state: resolved_host.state,
328            host_memory: resolved_host.pool,
329            device_memory: None,
330            memory_decisions: Default::default(),
331            normalization_cache: Default::default(),
332            #[cfg(feature = "wgpu")]
333            wgpu: None,
334            #[cfg(feature = "mpi")]
335            communicator: None,
336        }
337    }
338}
339
340impl Execution {
341    /// Creates a non-distributed execution context from `options`.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`RuntimeError`] when the requested backend or precision is
346    /// unavailable, GPU initialization fails, or the CPU thread pool cannot be
347    /// created.
348    pub fn local(options: ExecutionOptions) -> RuntimeResult<Self> {
349        let resolved_host = resolve_host_memory(options.memory.host)?;
350        let memory_state = resolved_host.state;
351        let host_memory = resolved_host.pool;
352        #[cfg(feature = "wgpu")]
353        let resolved_gpu = match &options.device {
354            Device::Gpu(gpu_options) => Some(resolve_gpu(
355                &memory_state,
356                gpu_options,
357                options.precision,
358                options.memory.device,
359            )?),
360            _ => None,
361        };
362        #[cfg(feature = "wgpu")]
363        let wgpu = resolved_gpu.as_ref().map(|gpu| Arc::clone(&gpu.context));
364        #[cfg(feature = "wgpu")]
365        let device_memory = resolved_gpu.map(|gpu| gpu.memory);
366        #[cfg(not(feature = "wgpu"))]
367        let device_memory = None;
368        #[cfg(not(feature = "wgpu"))]
369        if let Device::Gpu(gpu_options) = &options.device {
370            return Err(ExecutionError::GpuUnavailable(gpu_options.backend).into());
371        }
372        let cpu_options = match &options.device {
373            Device::Cpu(options) => options.clone(),
374            Device::Auto | Device::Gpu(_) => CpuOptions::default(),
375        };
376        let cpu = resolve_cpu(cpu_options)?;
377        let precision =
378            resolve_precision(options.precision, matches!(options.device, Device::Gpu(_)));
379        Ok(Self {
380            requested_device: options.device,
381            precision,
382            autodiff: options.autodiff,
383            normalization: options.normalization,
384            threads: cpu.threads,
385            jit: cpu.jit,
386            pool: cpu.pool,
387            partitioning: options.partitioning,
388            memory_state,
389            host_memory,
390            device_memory,
391            memory_decisions: Default::default(),
392            normalization_cache: Default::default(),
393            #[cfg(feature = "wgpu")]
394            wgpu,
395            #[cfg(feature = "mpi")]
396            communicator: None,
397        })
398    }
399
400    #[cfg(feature = "mpi")]
401    /// Creates a distributed execution context over the supplied MPI communicator.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`RuntimeError`] when local execution initialization fails.
406    pub fn distributed<C>(options: ExecutionOptions, world: &C) -> RuntimeResult<Self>
407    where
408        C: Communicator,
409    {
410        let local_processes = mpi_local_process_count(world.size());
411        let mut options = options;
412        options.memory.host = shared_mpi_budget(options.memory.host, local_processes);
413        options.memory.device = options
414            .memory
415            .device
416            .map(|budget| shared_mpi_budget(budget, local_processes));
417        let mut execution = Self::local(options)?;
418        execution.record_memory_decision(MemoryDecision {
419            label: "mpi-memory-share".into(),
420            fixed_bytes: 0,
421            bytes_per_event: 0,
422            chunk_events: 0,
423            estimated_peak_bytes: 0,
424            actual_high_water_bytes: None,
425            strategy: format!("equal-share-across-{local_processes}-local-ranks"),
426        });
427        execution.communicator = Some(Arc::new(world.duplicate()));
428        Ok(execution)
429    }
430
431    /// Returns the device requested when this context was created.
432    pub fn requested_device(&self) -> &Device {
433        &self.requested_device
434    }
435
436    #[cfg(feature = "wgpu")]
437    pub(crate) fn wgpu_context(&self) -> Option<&Arc<laddu_wgpu::WgpuContext>> {
438        self.wgpu.as_ref()
439    }
440
441    /// Returns the resolved numeric precision.
442    pub fn precision(&self) -> Precision {
443        self.precision
444    }
445
446    /// Returns the automatic-differentiation strategy.
447    pub fn autodiff_mode(&self) -> AutodiffMode {
448        self.autodiff
449    }
450
451    /// Returns the accepted-normalization policy.
452    pub fn normalization_mode(&self) -> NormalizationMode {
453        self.normalization
454    }
455
456    pub(crate) fn normalization_cache(&self) -> &Mutex<NormalizationCache> {
457        &self.normalization_cache
458    }
459
460    /// Returns the CPU worker-thread policy.
461    pub fn thread_policy(&self) -> ThreadPolicy {
462        self.threads
463    }
464
465    /// Returns the CPU JIT policy.
466    pub fn jit_policy(&self) -> JitPolicy {
467        self.jit
468    }
469
470    /// Returns the distributed dataset-partitioning strategy.
471    pub fn partitioning(&self) -> Partitioning {
472        self.partitioning
473    }
474
475    /// Returns the live memory state shared by this execution.
476    pub fn memory_state(&self) -> &MemoryState {
477        &self.memory_state
478    }
479
480    /// Returns the resolved host-memory pool.
481    pub fn host_memory(&self) -> &MemoryPool {
482        &self.host_memory
483    }
484
485    /// Returns the resolved accelerator-memory pool, if any.
486    pub fn device_memory(&self) -> Option<&MemoryPool> {
487        self.device_memory.as_ref()
488    }
489
490    /// Returns current physical-resource memory information.
491    pub fn memory_report(&self) -> MemoryReport {
492        self.memory_state.report()
493    }
494
495    /// Returns the resolved execution-pool reports.
496    pub fn memory_pool_reports(&self) -> Vec<MemoryPoolReport> {
497        std::iter::once(self.host_memory.report())
498            .chain(self.device_memory.as_ref().map(MemoryPool::report))
499            .collect()
500    }
501
502    /// Returns memory-derived decisions recorded by this execution.
503    pub fn memory_decisions(&self) -> Vec<MemoryDecision> {
504        self.memory_decisions
505            .lock()
506            .unwrap_or_else(|error| error.into_inner())
507            .clone()
508    }
509
510    /// Records a memory-planning decision for later diagnostics.
511    pub fn record_memory_decision(&self, decision: MemoryDecision) {
512        self.memory_decisions
513            .lock()
514            .unwrap_or_else(|error| error.into_inner())
515            .push(decision);
516    }
517
518    /// Returns the zero-based rank of this process.
519    pub fn rank(&self) -> usize {
520        #[cfg(feature = "mpi")]
521        if let Some(communicator) = &self.communicator {
522            return communicator.rank() as usize;
523        }
524        0
525    }
526
527    /// Returns the number of participating ranks.
528    pub fn nranks(&self) -> usize {
529        #[cfg(feature = "mpi")]
530        if let Some(communicator) = &self.communicator {
531            return communicator.size() as usize;
532        }
533        1
534    }
535
536    /// Returns whether this context spans more than one rank.
537    pub fn is_distributed(&self) -> bool {
538        self.nranks() > 1
539    }
540
541    #[allow(unused_mut)]
542    pub(crate) fn read_plan(&self, mut plan: ReadPlan) -> ReadPlan {
543        #[cfg(feature = "mpi")]
544        if let Some(communicator) = &self.communicator {
545            plan.distribution = laddu_data::io::Distribution::from_world(communicator.as_ref())
546                .with_partitioning(self.partitioning);
547        }
548        plan
549    }
550
551    pub(crate) fn sum_f64(&self, local: f64) -> f64 {
552        #[cfg(feature = "mpi")]
553        if let Some(communicator) = &self.communicator {
554            let mut global = 0.0;
555            communicator.all_reduce_into(&local, &mut global, SystemOperation::sum());
556            return global;
557        }
558        local
559    }
560
561    pub(crate) fn sum_usize(&self, local: usize) -> usize {
562        #[cfg(feature = "mpi")]
563        if let Some(communicator) = &self.communicator {
564            let local = local as u64;
565            let mut global = 0_u64;
566            communicator.all_reduce_into(&local, &mut global, SystemOperation::sum());
567            return global as usize;
568        }
569        local
570    }
571
572    pub(crate) fn sum_slice(&self, local: &[f64]) -> Vec<f64> {
573        #[cfg(feature = "mpi")]
574        if let Some(communicator) = &self.communicator {
575            let mut global = vec![0.0; local.len()];
576            communicator.all_reduce_into(local, &mut global, SystemOperation::sum());
577            return global;
578        }
579        local.to_vec()
580    }
581
582    pub(crate) fn all_succeeded(&self, local_success: bool) -> bool {
583        self.sum_usize(usize::from(local_success)) == self.nranks()
584    }
585
586    pub(crate) fn is_parallel(&self) -> bool {
587        self.threads != ThreadPolicy::Serial
588    }
589
590    pub(crate) fn install<R: Send>(&self, operation: impl FnOnce() -> R + Send) -> R {
591        match &self.pool {
592            Some(pool) => pool.install(operation),
593            None => operation(),
594        }
595    }
596}
597
598#[cfg(feature = "mpi")]
599fn shared_mpi_budget(budget: MemoryBudget, local_processes: u64) -> MemoryBudget {
600    let divisor = local_processes.max(1);
601    match budget {
602        MemoryBudget::Auto => MemoryBudget::PercentAvailable(0.80 / divisor as f64),
603        MemoryBudget::Bytes(bytes) => MemoryBudget::Bytes((bytes / divisor).max(1)),
604        MemoryBudget::PercentTotal(fraction) => {
605            MemoryBudget::PercentTotal(fraction / divisor as f64)
606        }
607        MemoryBudget::PercentAvailable(fraction) => {
608            MemoryBudget::PercentAvailable(fraction / divisor as f64)
609        }
610    }
611}
612
613#[cfg(feature = "mpi")]
614fn mpi_local_process_count(world_size: i32) -> u64 {
615    // Common launchers expose node-local process counts. Falling back to the
616    // world size is conservative on multi-node jobs and prevents accidental
617    // host/device overcommit when launcher metadata is unavailable.
618    const VARIABLES: [&str; 4] = [
619        "OMPI_COMM_WORLD_LOCAL_SIZE",
620        "MPI_LOCALNRANKS",
621        "MV2_COMM_WORLD_LOCAL_SIZE",
622        "SLURM_NTASKS_PER_NODE",
623    ];
624    VARIABLES
625        .iter()
626        .filter_map(|name| std::env::var(name).ok())
627        .find_map(|value| {
628            value
629                .split(|character: char| !character.is_ascii_digit())
630                .find(|part| !part.is_empty())
631                .and_then(|part| part.parse::<u64>().ok())
632                .filter(|count| *count > 0)
633        })
634        .unwrap_or_else(|| u64::try_from(world_size).unwrap_or(1).max(1))
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::RuntimeError;
641    use crate::execution::GpuBackend;
642
643    #[test]
644    fn execution_options_roundtrip_through_json() {
645        let options = ExecutionOptions {
646            device: Device::Gpu(GpuOptions {
647                backend: GpuBackend::Wgpu,
648                device: GpuDeviceSelector::PciBusId("0000:01:00.0".into()),
649            }),
650            precision: Precision::F64,
651            autodiff: AutodiffMode::Reverse,
652            normalization: NormalizationMode::Verify,
653            partitioning: Partitioning::FileGroups,
654            memory: MemoryPlan::host_device(
655                MemoryBudget::PercentAvailable(0.5),
656                MemoryBudget::Bytes(1 << 30),
657            ),
658        };
659
660        let json = serde_json::to_string(&options).unwrap();
661        assert_eq!(
662            serde_json::from_str::<ExecutionOptions>(&json).unwrap(),
663            options
664        );
665    }
666
667    #[test]
668    fn execution_selects_nested_cpu_options() {
669        let serial = Execution::local(ExecutionOptions {
670            device: Device::Cpu(CpuOptions {
671                threads: ThreadPolicy::Serial,
672                jit: JitPolicy::Disabled,
673            }),
674            ..ExecutionOptions::default()
675        })
676        .unwrap();
677        assert!(!serial.is_parallel());
678        assert_eq!(serial.jit_policy(), JitPolicy::Disabled);
679        assert_eq!(serial.precision(), Precision::F64);
680
681        let fixed = Execution::local(ExecutionOptions {
682            device: Device::Cpu(CpuOptions {
683                threads: ThreadPolicy::Fixed(2),
684                ..CpuOptions::default()
685            }),
686            ..ExecutionOptions::default()
687        })
688        .unwrap();
689        assert_eq!(fixed.install(rayon::current_num_threads), 2);
690    }
691
692    #[test]
693    fn unavailable_execution_modes_return_capability_errors() {
694        #[cfg(not(feature = "wgpu"))]
695        assert!(matches!(
696            Execution::local(ExecutionOptions {
697                device: Device::Gpu(GpuOptions {
698                    backend: GpuBackend::Wgpu,
699                    ..GpuOptions::default()
700                }),
701                ..ExecutionOptions::default()
702            }),
703            Err(RuntimeError::Execution(ExecutionError::GpuUnavailable(
704                GpuBackend::Wgpu
705            )))
706        ));
707        #[cfg(feature = "wgpu")]
708        assert!(
709            Execution::local(ExecutionOptions {
710                device: Device::Gpu(GpuOptions {
711                    backend: GpuBackend::Wgpu,
712                    ..GpuOptions::default()
713                }),
714                ..ExecutionOptions::default()
715            })
716            .is_ok()
717        );
718        let f32 = Execution::local(ExecutionOptions {
719            device: Device::Cpu(CpuOptions::default()),
720            precision: Precision::F32,
721            ..ExecutionOptions::default()
722        })
723        .unwrap();
724        assert_eq!(f32.precision(), Precision::F32);
725
726        let reverse = Execution::local(ExecutionOptions {
727            autodiff: AutodiffMode::Reverse,
728            ..ExecutionOptions::default()
729        })
730        .unwrap();
731        assert_eq!(reverse.autodiff_mode(), AutodiffMode::Reverse);
732
733        let reverse_f32 = Execution::local(ExecutionOptions {
734            precision: Precision::F32,
735            autodiff: AutodiffMode::Reverse,
736            ..ExecutionOptions::default()
737        })
738        .unwrap();
739        assert_eq!(reverse_f32.precision(), Precision::F32);
740        assert_eq!(reverse_f32.autodiff_mode(), AutodiffMode::Reverse);
741    }
742
743    #[test]
744    fn focused_resource_resolution_covers_cpu_policy_matrix() {
745        let cases = [
746            (ThreadPolicy::Auto, JitPolicy::Disabled, false),
747            (ThreadPolicy::Serial, JitPolicy::Auto, false),
748            (ThreadPolicy::Fixed(2), JitPolicy::Disabled, true),
749        ];
750        for (threads, jit, has_pool) in cases {
751            let resolved = resolve_cpu(CpuOptions { threads, jit }).unwrap();
752            assert_eq!(resolved.threads, threads);
753            assert_eq!(resolved.jit, jit);
754            assert_eq!(resolved.pool.is_some(), has_pool);
755        }
756
757        assert!(matches!(
758            resolve_cpu(CpuOptions {
759                threads: ThreadPolicy::Fixed(0),
760                jit: JitPolicy::Disabled,
761            }),
762            Err(RuntimeError::Execution(ExecutionError::ZeroThreads))
763        ));
764
765        #[cfg(not(feature = "jit"))]
766        assert!(matches!(
767            resolve_cpu(CpuOptions {
768                threads: ThreadPolicy::Auto,
769                jit: JitPolicy::Enabled,
770            }),
771            Err(RuntimeError::Execution(ExecutionError::JitUnavailable))
772        ));
773        #[cfg(feature = "jit")]
774        assert!(
775            resolve_cpu(CpuOptions {
776                threads: ThreadPolicy::Auto,
777                jit: JitPolicy::Enabled,
778            })
779            .is_ok()
780        );
781    }
782
783    #[test]
784    fn focused_precision_resolution_uses_device_defaults() {
785        let cases = [
786            (Precision::Auto, false, Precision::F64),
787            (Precision::Auto, true, Precision::F32),
788            (Precision::F32, false, Precision::F32),
789            (Precision::F64, true, Precision::F64),
790        ];
791        for (requested, gpu_requested, expected) in cases {
792            assert_eq!(resolve_precision(requested, gpu_requested), expected);
793        }
794    }
795}