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
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 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 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 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 pub fn precision(&self) -> Precision {
443 self.precision
444 }
445
446 pub fn autodiff_mode(&self) -> AutodiffMode {
448 self.autodiff
449 }
450
451 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 pub fn thread_policy(&self) -> ThreadPolicy {
462 self.threads
463 }
464
465 pub fn jit_policy(&self) -> JitPolicy {
467 self.jit
468 }
469
470 pub fn partitioning(&self) -> Partitioning {
472 self.partitioning
473 }
474
475 pub fn memory_state(&self) -> &MemoryState {
477 &self.memory_state
478 }
479
480 pub fn host_memory(&self) -> &MemoryPool {
482 &self.host_memory
483 }
484
485 pub fn device_memory(&self) -> Option<&MemoryPool> {
487 self.device_memory.as_ref()
488 }
489
490 pub fn memory_report(&self) -> MemoryReport {
492 self.memory_state.report()
493 }
494
495 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 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 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 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 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 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 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}