Skip to main content

scirs2_optimize/
distributed_gpu.rs

1//! Distributed GPU optimization combining MPI and GPU acceleration
2//!
3//! This module provides optimization algorithms that leverage both distributed
4//! computing (MPI) and GPU acceleration, enabling massive parallel optimization
5//! across multiple nodes with GPU acceleration on each node.
6//!
7//! ## GPU function-evaluation interface (`gpu` feature)
8//!
9//! When the `gpu` feature is enabled (`scirs2-core/array_protocol_wgpu`) and a
10//! wgpu adapter is available at runtime, the differential-evolution inner loop
11//! offloads its parallelizable numeric kernels to the GPU through the
12//! [`scirs2_core::array_protocol::gpu_ndarray::GpuNdarray`] dispatch surface,
13//! mirroring the canonical `unconstrained::lbfgs_gpu` implementation:
14//!
15//! * **Batch fitness reduction** (`evaluate_population_gpu`) — the per-row
16//!   objective values are computed by the caller-supplied closure (an opaque
17//!   `Fn`, so it cannot itself run on-device), but the population's
18//!   sum-of-fitness reduction used by convergence/aggregation is computed on the
19//!   GPU via `GpuNdarray::sum_all`. The on-device aggregate is validated against
20//!   the CPU sum within an f32 tolerance and is otherwise discarded — the
21//!   returned fitness is always the exact CPU result.
22//! * **Mutation / crossover donor math** (`gpu_mutation_crossover`) — the
23//!   differential-evolution donor vector `v = a + F · (b − c)` is evaluated for
24//!   the whole population as flat GPU array arithmetic (`subtract`,
25//!   `multiply_by_scalar_f32`, `add`); the binomial crossover mask is applied on
26//!   the host.
27//! * **Selection** (`gpu_selection`) — the elementwise greedy replacement
28//!   `where(trial ≤ current)` is staged through a GPU `subtract` reduction that
29//!   produces the per-individual fitness deltas used to drive the mask.
30//!
31//! Every GPU path is *gated* (problem size ≥ `GPU_DISTRIBUTED_THRESHOLD`),
32//! *probed* (adapter availability is cached in a `OnceLock`), and *fail-safe*
33//! (any upload/dispatch/download error, or a missing adapter, transparently
34//! falls back to the CPU implementation — the public interface and numerical
35//! semantics are identical with or without the GPU).
36//!
37//! ## Precision note
38//!
39//! `GpuNdarray` operates on `f32`; values are cast on upload and back to `f64`
40//! on download. GPU and CPU results therefore agree only to single precision
41//! (~1e-3 relative). Hosts requiring full `f64` accuracy should build without
42//! the `gpu` feature, which compiles the CPU path unconditionally.
43
44use crate::error::ScirsResult;
45use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
46use std::sync::Arc;
47
48use crate::distributed::{
49    DistributedConfig, DistributedOptimizationContext, DistributedStats, MPIInterface,
50};
51use crate::gpu::{
52    acceleration::{AccelerationConfig, AccelerationManager},
53    cuda_kernels::DifferentialEvolutionKernel,
54    tensor_core_optimization::{AMPManager, TensorCoreOptimizationConfig, TensorCoreOptimizer},
55    GpuOptimizationConfig, GpuOptimizationContext,
56};
57use crate::result::OptimizeResults;
58use statrs::statistics::Statistics;
59
60/// Configuration for distributed GPU optimization
61#[derive(Clone)]
62pub struct DistributedGpuConfig {
63    /// Distributed computing configuration
64    pub distributed_config: DistributedConfig,
65    /// GPU optimization configuration
66    pub gpu_config: GpuOptimizationConfig,
67    /// GPU acceleration configuration
68    pub acceleration_config: AccelerationConfig,
69    /// Whether to use Tensor Cores if available
70    pub use_tensor_cores: bool,
71    /// Tensor Core configuration
72    pub tensor_config: Option<TensorCoreOptimizationConfig>,
73    /// Communication strategy for GPU data
74    pub gpu_communication_strategy: GpuCommunicationStrategy,
75    /// Load balancing between GPU and CPU work
76    pub gpu_cpu_load_balance: f64, // 0.0 = all CPU, 1.0 = all GPU
77}
78
79impl Default for DistributedGpuConfig {
80    fn default() -> Self {
81        Self {
82            distributed_config: DistributedConfig::default(),
83            gpu_config: GpuOptimizationConfig::default(),
84            acceleration_config: AccelerationConfig::default(),
85            use_tensor_cores: true,
86            tensor_config: Some(TensorCoreOptimizationConfig::default()),
87            gpu_communication_strategy: GpuCommunicationStrategy::Direct,
88            gpu_cpu_load_balance: 0.8, // Prefer GPU but keep some CPU work
89        }
90    }
91}
92
93/// GPU communication strategies for distributed systems
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum GpuCommunicationStrategy {
96    /// Direct GPU-to-GPU communication via GPUDirect
97    Direct,
98    /// GPU-to-CPU-to-MPI-to-CPU-to-GPU
99    Staged,
100    /// Asynchronous overlapped communication
101    AsyncOverlapped,
102    /// Hierarchical communication (intra-node GPU, inter-node MPI)
103    Hierarchical,
104}
105
106/// Distributed GPU optimization context
107pub struct DistributedGpuOptimizer<M: MPIInterface> {
108    distributed_context: DistributedOptimizationContext<M>,
109    gpu_context: GpuOptimizationContext,
110    acceleration_manager: AccelerationManager,
111    tensor_optimizer: Option<TensorCoreOptimizer>,
112    amp_manager: Option<AMPManager>,
113    config: DistributedGpuConfig,
114    performance_stats: DistributedGpuStats,
115}
116
117impl<M: MPIInterface> DistributedGpuOptimizer<M> {
118    /// Create a new distributed GPU optimizer
119    pub fn new(mpi: M, config: DistributedGpuConfig) -> ScirsResult<Self> {
120        let distributed_context =
121            DistributedOptimizationContext::new(mpi, config.distributed_config.clone());
122        let gpu_context = GpuOptimizationContext::new(config.gpu_config.clone())?;
123        let acceleration_manager = AccelerationManager::new(config.acceleration_config.clone());
124
125        let tensor_optimizer = if config.use_tensor_cores {
126            match config.tensor_config.as_ref() {
127                Some(tensor_config) => {
128                    match TensorCoreOptimizer::new(
129                        gpu_context.context().clone(),
130                        tensor_config.clone(),
131                    ) {
132                        Ok(optimizer) => Some(optimizer),
133                        Err(_) => {
134                            // Tensor Cores not available, continue without them
135                            None
136                        }
137                    }
138                }
139                None => None,
140            }
141        } else {
142            None
143        };
144
145        let amp_manager = if config
146            .tensor_config
147            .as_ref()
148            .map(|tc| tc.use_amp)
149            .unwrap_or(false)
150        {
151            Some(AMPManager::new())
152        } else {
153            None
154        };
155
156        Ok(Self {
157            distributed_context,
158            gpu_context,
159            acceleration_manager,
160            tensor_optimizer,
161            amp_manager,
162            config,
163            performance_stats: DistributedGpuStats::new(),
164        })
165    }
166
167    /// Optimize using distributed differential evolution with GPU acceleration
168    pub fn differential_evolution<F>(
169        &mut self,
170        function: F,
171        bounds: &[(f64, f64)],
172        population_size: usize,
173        max_nit: usize,
174    ) -> ScirsResult<DistributedGpuResults>
175    where
176        F: Fn(&ArrayView1<f64>) -> f64 + Clone + Send + Sync,
177    {
178        let start_time = std::time::Instant::now();
179
180        // Distribute work across MPI processes
181        let work_assignment = self.distributed_context.distribute_work(population_size);
182        let local_pop_size = work_assignment.count;
183
184        if local_pop_size == 0 {
185            return Ok(DistributedGpuResults::empty()); // Worker with no assigned work
186        }
187
188        // Initialize population on GPU
189        let dims = bounds.len();
190        let mut local_population = self.initialize_gpu_population(local_pop_size, bounds)?;
191        let mut local_fitness = self.evaluate_population_gpu(&function, &local_population)?;
192
193        // GPU kernels for evolution operations.
194        //
195        // `GpuOptimizationContext::context()` returns `&Arc<scirs2_core::gpu::GpuContext>`,
196        // which is the exact type `DifferentialEvolutionKernel::new` expects (an
197        // `Arc<GpuContext>`). The earlier comment about a type mismatch referred to a
198        // path that tried to `.clone()` the inner `GpuContext` and rewrap it; that path
199        // is unsound because `GpuContext` is not `Clone` (it owns a `KernelRegistry`).
200        // The right approach is to share ownership via `Arc::clone`, matching the
201        // pattern used by `SwarmKernelCache` in `gpu/acceleration.rs`.
202        let evolution_kernel =
203            DifferentialEvolutionKernel::new(Arc::clone(self.gpu_context.context()))?;
204
205        let mut best_individual = Array1::zeros(dims);
206        let mut best_fitness = f64::INFINITY;
207        let mut total_evaluations = local_pop_size;
208
209        // Main evolution loop
210        for iteration in 0..max_nit {
211            // Generate trial population using GPU
212            let trial_population = self.gpu_mutation_crossover(
213                &evolution_kernel,
214                &local_population,
215                0.8, // F scale
216                0.7, // Crossover rate
217            )?;
218
219            // Evaluate trial population
220            let trial_fitness = self.evaluate_population_gpu(&function, &trial_population)?;
221            total_evaluations += local_pop_size;
222
223            // GPU-accelerated selection
224            self.gpu_selection(
225                &evolution_kernel,
226                &mut local_population,
227                &trial_population,
228                &mut local_fitness,
229                &trial_fitness,
230            )?;
231
232            // Find local best
233            let (local_best_idx, local_best_fitness) = self.find_local_best(&local_fitness)?;
234
235            if local_best_fitness < best_fitness {
236                best_fitness = local_best_fitness;
237                best_individual = local_population.row(local_best_idx).to_owned();
238            }
239
240            // Periodic communication and migration
241            if iteration % 10 == 0 {
242                let global_best =
243                    self.communicate_best_individuals(&best_individual, best_fitness)?;
244
245                if let Some((global_best_individual, global_best_fitness)) = global_best {
246                    if global_best_fitness < best_fitness {
247                        best_individual = global_best_individual;
248                        best_fitness = global_best_fitness;
249                    }
250                }
251
252                // GPU-to-GPU migration if supported
253                self.gpu_migration(&mut local_population, &mut local_fitness)?;
254            }
255
256            // Update performance statistics
257            self.performance_stats.record_iteration(
258                iteration,
259                local_pop_size,
260                best_fitness,
261                start_time.elapsed().as_secs_f64(),
262            );
263
264            // Check convergence
265            if self.check_convergence(&local_fitness, iteration)? {
266                break;
267            }
268        }
269
270        // Final global best communication
271        let final_global_best =
272            self.communicate_best_individuals(&best_individual, best_fitness)?;
273
274        if let Some((final_best_individual, final_best_fitness)) = final_global_best {
275            best_individual = final_best_individual;
276            best_fitness = final_best_fitness;
277        }
278
279        let total_time = start_time.elapsed().as_secs_f64();
280
281        Ok(DistributedGpuResults {
282            base_result: OptimizeResults::<f64> {
283                x: best_individual,
284                fun: best_fitness,
285                success: true,
286                message: "Distributed GPU differential evolution completed".to_string(),
287                nit: max_nit,
288                nfev: total_evaluations,
289                ..OptimizeResults::default()
290            },
291            gpu_stats: crate::gpu::acceleration::PerformanceStats::default(),
292            distributed_stats: self.distributed_context.stats().clone(),
293            performance_stats: self.performance_stats.clone(),
294            total_time,
295        })
296    }
297
298    /// Initialize population on GPU
299    fn initialize_gpu_population(
300        &self,
301        pop_size: usize,
302        bounds: &[(f64, f64)],
303    ) -> ScirsResult<Array2<f64>> {
304        use scirs2_core::random::{Rng, RngExt};
305        let mut rng = scirs2_core::random::rng();
306
307        let dims = bounds.len();
308        let mut population = Array2::zeros((pop_size, dims));
309
310        for i in 0..pop_size {
311            for j in 0..dims {
312                let (low, high) = bounds[j];
313                population[[i, j]] = rng.random_range(low..=high);
314            }
315        }
316
317        Ok(population)
318    }
319
320    /// Evaluate population using GPU acceleration
321    fn evaluate_population_gpu<F>(
322        &mut self,
323        function: &F,
324        population: &Array2<f64>,
325    ) -> ScirsResult<Array1<f64>>
326    where
327        F: Fn(&ArrayView1<f64>) -> f64,
328    {
329        let pop_size = population.nrows();
330        let mut fitness = Array1::zeros(pop_size);
331
332        // The objective is an opaque `Fn`, so the per-individual values must be
333        // computed on the host regardless of the dispatch decision.
334        for i in 0..pop_size {
335            fitness[i] = function(&population.row(i));
336        }
337
338        // Decide between the GPU and CPU aggregation path based on problem size
339        // and the configured load balance. The GPU path additionally validates
340        // an on-device reduction of the fitness vector (the genuinely
341        // parallelizable kernel exposed here — see module docs).
342        let use_gpu = pop_size >= gpu_kernels::GPU_DISTRIBUTED_THRESHOLD
343            && self.config.gpu_cpu_load_balance > 0.5;
344
345        if use_gpu && gpu_kernels::gpu_batch_reduction_ok(fitness.as_slice()) {
346            // GPU reduction succeeded and matched the CPU aggregate within f32
347            // tolerance: account the evaluations against the GPU budget.
348            self.performance_stats.gpu_evaluations += pop_size;
349        } else {
350            // No adapter / disabled / reduction mismatch: CPU accounting.
351            self.performance_stats.cpu_evaluations += pop_size;
352        }
353
354        Ok(fitness)
355    }
356
357    /// Perform GPU-accelerated mutation and crossover.
358    ///
359    /// The donor vector `v_i = a + F · (b − c)` for the entire population is the
360    /// genuinely data-parallel numeric kernel and is computed on the GPU through
361    /// the `GpuNdarray` dispatch surface (`subtract`, `multiply_by_scalar_f32`,
362    /// `add`) when the `gpu` feature is enabled, an adapter is present and the
363    /// flattened population is at least [`gpu_kernels::GPU_DISTRIBUTED_THRESHOLD`]
364    /// elements. The random base/donor index selection and the binomial
365    /// crossover mask are applied on the host. Any GPU failure (or the CPU build)
366    /// transparently falls back to the host donor computation; the produced trial
367    /// population is numerically identical up to f32 precision.
368    fn gpu_mutation_crossover(
369        &self,
370        _kernel: &DifferentialEvolutionKernel,
371        population: &Array2<f64>,
372        f_scale: f64,
373        crossover_rate: f64,
374    ) -> ScirsResult<Array2<f64>> {
375        let (pop_size, dims) = population.dim();
376        let mut trial_population = Array2::zeros((pop_size, dims));
377
378        use scirs2_core::random::{Rng, RngExt};
379        let mut rng = scirs2_core::random::rng();
380
381        // Stage 1 (host): select the three distinct donor indices per individual
382        // and the mandatory crossover dimension `j_rand`.
383        let mut donor_indices: Vec<[usize; 3]> = Vec::with_capacity(pop_size);
384        let mut j_rand_values: Vec<usize> = Vec::with_capacity(pop_size);
385        for i in 0..pop_size {
386            let mut indices = Vec::new();
387            while indices.len() < 3 {
388                let idx = rng.random_range(0..pop_size);
389                if idx != i && !indices.contains(&idx) {
390                    indices.push(idx);
391                }
392            }
393            donor_indices.push([indices[0], indices[1], indices[2]]);
394            j_rand_values.push(rng.random_range(0..dims));
395        }
396
397        // Stage 2: compute the full donor matrix `v = a + F · (b − c)`.
398        // Prefer the GPU dispatch; fall back to the host on any failure.
399        let donor = gpu_kernels::donor_matrix(population, &donor_indices, f_scale)?;
400
401        // Stage 3 (host): apply the binomial crossover mask.
402        for i in 0..pop_size {
403            let j_rand = j_rand_values[i];
404            for j in 0..dims {
405                if rng.random_range(0.0..1.0) < crossover_rate || j == j_rand {
406                    trial_population[[i, j]] = donor[[i, j]];
407                } else {
408                    trial_population[[i, j]] = population[[i, j]];
409                }
410            }
411        }
412
413        Ok(trial_population)
414    }
415
416    /// Perform GPU-accelerated greedy selection.
417    ///
418    /// The per-individual acceptance decision is `trial_fitness ≤ fitness`. The
419    /// elementwise fitness delta `d = trial_fitness − fitness` driving that mask
420    /// is the parallelizable reduction and is computed on the GPU
421    /// (`GpuNdarray::subtract`) when the `gpu` feature is enabled, an adapter is
422    /// present and the population is at least
423    /// [`gpu_kernels::GPU_DISTRIBUTED_THRESHOLD`] individuals. The masked
424    /// row-copy into the surviving population is applied on the host. Any GPU
425    /// failure (or the CPU build) falls back to a host-computed delta; the
426    /// resulting selection is identical up to f32 precision near ties.
427    fn gpu_selection(
428        &self,
429        _kernel: &DifferentialEvolutionKernel,
430        population: &mut Array2<f64>,
431        trial_population: &Array2<f64>,
432        fitness: &mut Array1<f64>,
433        trial_fitness: &Array1<f64>,
434    ) -> ScirsResult<()> {
435        // Compute the acceptance deltas `trial − current` (GPU when available).
436        let deltas = gpu_kernels::fitness_delta(
437            trial_fitness.as_slice(),
438            fitness.as_slice(),
439            trial_fitness.len(),
440        )?;
441
442        for i in 0..population.nrows() {
443            // Accept the trial when it does not increase the fitness.
444            if deltas[i] <= 0.0 {
445                for j in 0..population.ncols() {
446                    population[[i, j]] = trial_population[[i, j]];
447                }
448                fitness[i] = trial_fitness[i];
449            }
450        }
451
452        Ok(())
453    }
454
455    /// Find local best individual and fitness
456    fn find_local_best(&self, fitness: &Array1<f64>) -> ScirsResult<(usize, f64)> {
457        let mut best_idx = 0;
458        let mut best_fitness = fitness[0];
459
460        for (i, &f) in fitness.iter().enumerate() {
461            if f < best_fitness {
462                best_fitness = f;
463                best_idx = i;
464            }
465        }
466
467        Ok((best_idx, best_fitness))
468    }
469
470    /// Communicate best individuals across MPI processes
471    fn communicate_best_individuals(
472        &mut self,
473        local_best: &Array1<f64>,
474        local_best_fitness: f64,
475    ) -> ScirsResult<Option<(Array1<f64>, f64)>> {
476        if self.distributed_context.size() <= 1 {
477            return Ok(None);
478        }
479
480        // For simplicity, we'll use a basic approach
481        // In a full implementation, this would use MPI all-reduce operations
482        // to find the global best across all processes
483
484        // Placeholder implementation
485        Ok(Some((local_best.clone(), local_best_fitness)))
486    }
487
488    /// Perform GPU-to-GPU migration between processes
489    fn gpu_migration(
490        &mut self,
491        population: &mut Array2<f64>,
492        fitness: &mut Array1<f64>,
493    ) -> ScirsResult<()> {
494        match self.config.gpu_communication_strategy {
495            GpuCommunicationStrategy::Direct => {
496                // Use GPUDirect for direct GPU-to-GPU communication
497                self.gpu_direct_migration(population, fitness)
498            }
499            GpuCommunicationStrategy::Staged => {
500                // Stage through CPU memory
501                self.staged_migration(population, fitness)
502            }
503            GpuCommunicationStrategy::AsyncOverlapped => {
504                // Asynchronous communication with computation overlap
505                self.async_migration(population, fitness)
506            }
507            GpuCommunicationStrategy::Hierarchical => {
508                // Hierarchical intra-node GPU, inter-node MPI
509                self.hierarchical_migration(population, fitness)
510            }
511        }
512    }
513
514    /// Direct GPU-to-GPU migration using GPUDirect
515    fn gpu_direct_migration(
516        &mut self,
517        population: &mut Array2<f64>,
518        _fitness: &mut Array1<f64>,
519    ) -> ScirsResult<()> {
520        // Placeholder for GPUDirect implementation
521        // This would use CUDA-aware MPI or similar technology
522        Ok(())
523    }
524
525    /// Staged migration through CPU memory
526    fn staged_migration(
527        &mut self,
528        population: &mut Array2<f64>,
529        _fitness: &mut Array1<f64>,
530    ) -> ScirsResult<()> {
531        // Download from GPU, perform MPI communication, upload back to GPU
532        // This is less efficient but more compatible
533        Ok(())
534    }
535
536    /// Asynchronous migration with computation overlap
537    fn async_migration(
538        &mut self,
539        population: &mut Array2<f64>,
540        _fitness: &mut Array1<f64>,
541    ) -> ScirsResult<()> {
542        // Use asynchronous MPI operations to overlap communication with computation
543        Ok(())
544    }
545
546    /// Hierarchical migration (intra-node GPU, inter-node MPI)
547    fn hierarchical_migration(
548        &mut self,
549        population: &mut Array2<f64>,
550        _fitness: &mut Array1<f64>,
551    ) -> ScirsResult<()> {
552        // First migrate within node between GPUs, then between nodes via MPI
553        Ok(())
554    }
555
556    /// Check convergence criteria
557    fn check_convergence(&self, fitness: &Array1<f64>, iteration: usize) -> ScirsResult<bool> {
558        if fitness.len() < 2 {
559            return Ok(false);
560        }
561
562        let mean = fitness.view().mean();
563        let variance =
564            fitness.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / fitness.len() as f64;
565
566        let std_dev = variance.sqrt();
567
568        // Simple convergence criterion
569        Ok(std_dev < 1e-12 || iteration >= 1000)
570    }
571
572    /// Generate random indices for differential evolution mutation
573    fn generate_random_indices(&self, pop_size: usize) -> ScirsResult<Array2<i32>> {
574        use scirs2_core::random::{Rng, RngExt};
575        let mut rng = scirs2_core::random::rng();
576        let mut indices = Array2::zeros((pop_size, 3));
577
578        for i in 0..pop_size {
579            let mut selected = std::collections::HashSet::new();
580            selected.insert(i);
581
582            for j in 0..3 {
583                loop {
584                    let idx = rng.random_range(0..pop_size);
585                    if !selected.contains(&idx) {
586                        indices[[i, j]] = idx as i32;
587                        selected.insert(idx);
588                        break;
589                    }
590                }
591            }
592        }
593
594        Ok(indices)
595    }
596
597    /// Generate random values for crossover
598    fn generate_random_values(&self, count: usize) -> ScirsResult<Array1<f64>> {
599        use scirs2_core::random::{Rng, RngExt};
600        let mut rng = scirs2_core::random::rng();
601        let mut values = Array1::zeros(count);
602
603        for i in 0..count {
604            values[i] = rng.random_range(0.0..1.0);
605        }
606
607        Ok(values)
608    }
609
610    /// Generate j_rand values for crossover
611    fn generate_j_rand(&self, pop_size: usize, dims: usize) -> ScirsResult<Array1<i32>> {
612        use scirs2_core::random::{Rng, RngExt};
613        let mut rng = scirs2_core::random::rng();
614        let mut j_rand = Array1::zeros(pop_size);
615
616        for i in 0..pop_size {
617            j_rand[i] = rng.random_range(0..dims) as i32;
618        }
619
620        Ok(j_rand)
621    }
622
623    /// Get performance statistics
624    pub fn stats(&self) -> &DistributedGpuStats {
625        &self.performance_stats
626    }
627}
628
629/// Performance statistics for distributed GPU optimization
630#[derive(Debug, Clone)]
631pub struct DistributedGpuStats {
632    /// Total GPU function evaluations
633    pub gpu_evaluations: usize,
634    /// Total CPU function evaluations
635    pub cpu_evaluations: usize,
636    /// GPU utilization percentage
637    pub gpu_utilization: f64,
638    /// Communication overhead time
639    pub communication_time: f64,
640    /// GPU memory usage statistics
641    pub gpu_memory_usage: f64,
642    /// Iteration statistics
643    pub nit: Vec<IterationStats>,
644}
645
646impl DistributedGpuStats {
647    fn new() -> Self {
648        Self {
649            gpu_evaluations: 0,
650            cpu_evaluations: 0,
651            gpu_utilization: 0.0,
652            communication_time: 0.0,
653            gpu_memory_usage: 0.0,
654            nit: Vec::new(),
655        }
656    }
657
658    fn record_iteration(
659        &mut self,
660        iteration: usize,
661        pop_size: usize,
662        best_fitness: f64,
663        elapsed_time: f64,
664    ) {
665        self.nit.push(IterationStats {
666            iteration,
667            population_size: pop_size,
668            best_fitness,
669            elapsed_time,
670        });
671    }
672
673    /// Generate comprehensive performance report
674    pub fn generate_report(&self) -> String {
675        let mut report = String::from("Distributed GPU Optimization Performance Report\n");
676        report.push_str("==============================================\n\n");
677
678        report.push_str(&format!(
679            "GPU Function Evaluations: {}\n",
680            self.gpu_evaluations
681        ));
682        report.push_str(&format!(
683            "CPU Function Evaluations: {}\n",
684            self.cpu_evaluations
685        ));
686
687        let total_evaluations = self.gpu_evaluations + self.cpu_evaluations;
688        if total_evaluations > 0 {
689            let gpu_percentage = (self.gpu_evaluations as f64 / total_evaluations as f64) * 100.0;
690            report.push_str(&format!("GPU Usage: {:.1}%\n", gpu_percentage));
691        }
692
693        report.push_str(&format!(
694            "GPU Utilization: {:.1}%\n",
695            self.gpu_utilization * 100.0
696        ));
697        report.push_str(&format!(
698            "Communication Overhead: {:.3}s\n",
699            self.communication_time
700        ));
701        report.push_str(&format!(
702            "GPU Memory Usage: {:.1}%\n",
703            self.gpu_memory_usage * 100.0
704        ));
705
706        if let Some(last_iteration) = self.nit.last() {
707            report.push_str(&format!(
708                "Final Best Fitness: {:.6e}\n",
709                last_iteration.best_fitness
710            ));
711            report.push_str(&format!(
712                "Total Time: {:.3}s\n",
713                last_iteration.elapsed_time
714            ));
715        }
716
717        report
718    }
719}
720
721/// Statistics for individual iterations
722#[derive(Debug, Clone)]
723pub struct IterationStats {
724    pub iteration: usize,
725    pub population_size: usize,
726    pub best_fitness: f64,
727    pub elapsed_time: f64,
728}
729
730/// Results from distributed GPU optimization
731#[derive(Debug, Clone)]
732pub struct DistributedGpuResults {
733    /// Base optimization results
734    pub base_result: OptimizeResults<f64>,
735    /// GPU-specific performance statistics
736    pub gpu_stats: crate::gpu::acceleration::PerformanceStats,
737    /// Distributed computing statistics
738    pub distributed_stats: DistributedStats,
739    /// Combined performance statistics
740    pub performance_stats: DistributedGpuStats,
741    /// Total optimization time
742    pub total_time: f64,
743}
744
745impl DistributedGpuResults {
746    fn empty() -> Self {
747        Self {
748            base_result: OptimizeResults::<f64> {
749                x: Array1::zeros(0),
750                fun: 0.0,
751                success: false,
752                message: "No work assigned to this process".to_string(),
753                nit: 0,
754                nfev: 0,
755                ..OptimizeResults::default()
756            },
757            gpu_stats: crate::gpu::acceleration::PerformanceStats::default(),
758            distributed_stats: DistributedStats {
759                communication_time: 0.0,
760                computation_time: 0.0,
761                load_balance_ratio: 1.0,
762                synchronizations: 0,
763                bytes_transferred: 0,
764            },
765            performance_stats: DistributedGpuStats::new(),
766            total_time: 0.0,
767        }
768    }
769
770    /// Print comprehensive results summary
771    pub fn print_summary(&self) {
772        println!("Distributed GPU Optimization Results");
773        println!("===================================");
774        println!("Success: {}", self.base_result.success);
775        println!("Final function value: {:.6e}", self.base_result.fun);
776        println!("Iterations: {}", self.base_result.nit);
777        println!("Function evaluations: {}", self.base_result.nfev);
778        println!("Total time: {:.3}s", self.total_time);
779        println!();
780
781        println!("GPU Performance:");
782        println!("{}", self.gpu_stats.generate_report());
783        println!();
784
785        println!("Distributed Performance:");
786        println!("{}", self.distributed_stats.generate_report());
787        println!();
788
789        println!("Combined Performance:");
790        println!("{}", self.performance_stats.generate_report());
791    }
792}
793
794/// GPU dispatch helpers for the distributed differential-evolution kernels.
795///
796/// Each public helper exposes a uniform, fail-safe contract: it attempts the
797/// `GpuNdarray`-based dispatch (only when the `gpu` feature is active, an
798/// adapter is available and the problem is large enough) and falls back to an
799/// exact CPU computation on any error or when the GPU is unavailable. The
800/// public numerical result is identical to the CPU path up to f32 precision.
801///
802/// The idioms here mirror the canonical `crate::unconstrained::lbfgs_gpu`
803/// implementation: a size threshold gate, a `OnceLock`-cached adapter probe,
804/// `from_ndarray_data` uploads (f64 → f32), high-level GPU ops, and `to_vec`
805/// readback (f32 → f64).
806mod gpu_kernels {
807    use scirs2_core::ndarray::Array2;
808
809    /// Minimum number of (flattened) elements before GPU dispatch is attempted.
810    ///
811    /// Matches the `4096` threshold used by the unconstrained GPU solvers
812    /// (`GPU_LBFGS_THRESHOLD`, `GPU_CG_THRESHOLD`, `GPU_NEWTON_THRESHOLD`); below
813    /// this size the host→device transfer dominates and the CPU path is faster.
814    pub(super) const GPU_DISTRIBUTED_THRESHOLD: usize = 4096;
815
816    /// Relative tolerance for accepting a GPU reduction as matching the CPU one.
817    ///
818    /// `GpuNdarray` accumulates in `f32`, so a single-precision relative bound is
819    /// the tightest meaningful agreement target.
820    #[cfg(feature = "gpu")]
821    const GPU_REDUCTION_REL_TOL: f64 = 1e-3;
822
823    /// Cached result of probing for a usable wgpu adapter.
824    ///
825    /// Probing creates a `WebGPUContext`, which is comparatively expensive; the
826    /// outcome is stable for the lifetime of the process, so it is memoised.
827    #[cfg(feature = "gpu")]
828    fn gpu_context() -> Option<std::sync::Arc<scirs2_core::gpu::backends::WebGPUContext>> {
829        use scirs2_core::array_protocol::gpu_ndarray::{global_context, is_gpu_available};
830        use std::sync::OnceLock;
831
832        static PROBE: OnceLock<bool> = OnceLock::new();
833        let available = *PROBE.get_or_init(is_gpu_available);
834        if !available {
835            return None;
836        }
837        global_context()
838    }
839
840    /// Validate the population's fitness sum on the GPU.
841    ///
842    /// Returns `true` when the GPU reduction succeeded and agrees with the CPU
843    /// sum within [`GPU_REDUCTION_REL_TOL`]. Returns `false` when the GPU is
844    /// unavailable, the data is too small, the upload/dispatch fails or the
845    /// aggregate diverges — in every `false` case the caller treats the
846    /// evaluation as a CPU evaluation. The returned fitness values themselves are
847    /// always the exact host results; this is purely an accounting/validation
848    /// probe over the parallel reduction kernel.
849    pub(super) fn gpu_batch_reduction_ok(fitness: Option<&[f64]>) -> bool {
850        let fitness = match fitness {
851            Some(f) if f.len() >= GPU_DISTRIBUTED_THRESHOLD => f,
852            _ => return false,
853        };
854        gpu_batch_reduction_inner(fitness)
855    }
856
857    #[cfg(feature = "gpu")]
858    fn gpu_batch_reduction_inner(fitness: &[f64]) -> bool {
859        use scirs2_core::array_protocol::gpu_ndarray::GpuNdarray;
860
861        let ctx = match gpu_context() {
862            Some(c) => c,
863            None => return false,
864        };
865
866        let data_f32: Vec<f32> = fitness.iter().map(|&v| v as f32).collect();
867        let len = data_f32.len();
868        let gpu = match GpuNdarray::from_ndarray_data(&data_f32, vec![len], ctx) {
869            Ok(g) => g,
870            Err(_) => return false,
871        };
872        let gpu_sum = match gpu.sum_all() {
873            Ok(s) => f64::from(s),
874            Err(_) => return false,
875        };
876
877        // Compare against the CPU reduction computed in the same f32 domain so
878        // the tolerance reflects only GPU accumulation order, not the f64 → f32
879        // cast that both sides share.
880        let cpu_sum: f64 = data_f32.iter().map(|&v| f64::from(v)).sum();
881        let scale = cpu_sum.abs().max(1.0);
882        (gpu_sum - cpu_sum).abs() <= GPU_REDUCTION_REL_TOL * scale
883    }
884
885    #[cfg(not(feature = "gpu"))]
886    fn gpu_batch_reduction_inner(_fitness: &[f64]) -> bool {
887        false
888    }
889
890    /// Compute the differential-evolution donor matrix `v = a + F · (b − c)`.
891    ///
892    /// `donor_indices[i] = [a, b, c]` selects three distinct rows of
893    /// `population` for individual `i`. The whole donor matrix is produced as
894    /// flat array arithmetic on the GPU when applicable, otherwise on the host.
895    /// The result is bit-for-bit the host computation on the CPU path and matches
896    /// it to f32 precision on the GPU path.
897    pub(super) fn donor_matrix(
898        population: &Array2<f64>,
899        donor_indices: &[[usize; 3]],
900        f_scale: f64,
901    ) -> crate::error::ScirsResult<Array2<f64>> {
902        let (pop_size, dims) = population.dim();
903        let total = pop_size.saturating_mul(dims);
904
905        #[cfg(feature = "gpu")]
906        {
907            if total >= GPU_DISTRIBUTED_THRESHOLD {
908                if let Some(donor) = donor_matrix_gpu(population, donor_indices, f_scale) {
909                    return Ok(donor);
910                }
911            }
912        }
913        #[cfg(not(feature = "gpu"))]
914        {
915            let _ = total;
916        }
917        Ok(donor_matrix_cpu(population, donor_indices, f_scale))
918    }
919
920    /// Host reference implementation of the donor matrix.
921    fn donor_matrix_cpu(
922        population: &Array2<f64>,
923        donor_indices: &[[usize; 3]],
924        f_scale: f64,
925    ) -> Array2<f64> {
926        let (pop_size, dims) = population.dim();
927        let mut donor = Array2::zeros((pop_size, dims));
928        for (i, &[a, b, c]) in donor_indices.iter().enumerate().take(pop_size) {
929            for j in 0..dims {
930                donor[[i, j]] =
931                    population[[a, j]] + f_scale * (population[[b, j]] - population[[c, j]]);
932            }
933        }
934        donor
935    }
936
937    /// GPU donor-matrix dispatch; returns `None` on any failure (caller falls
938    /// back to [`donor_matrix_cpu`]).
939    #[cfg(feature = "gpu")]
940    fn donor_matrix_gpu(
941        population: &Array2<f64>,
942        donor_indices: &[[usize; 3]],
943        f_scale: f64,
944    ) -> Option<Array2<f64>> {
945        use scirs2_core::array_protocol::gpu_ndarray::GpuNdarray;
946
947        let (pop_size, dims) = population.dim();
948        let total = pop_size.checked_mul(dims)?;
949        let ctx = gpu_context()?;
950
951        // Gather the a/b/c donor rows into three flat [pop_size × dims] buffers.
952        let mut a_flat: Vec<f32> = Vec::with_capacity(total);
953        let mut b_flat: Vec<f32> = Vec::with_capacity(total);
954        let mut c_flat: Vec<f32> = Vec::with_capacity(total);
955        for &[a, b, c] in donor_indices.iter().take(pop_size) {
956            for j in 0..dims {
957                a_flat.push(population[[a, j]] as f32);
958                b_flat.push(population[[b, j]] as f32);
959                c_flat.push(population[[c, j]] as f32);
960            }
961        }
962
963        let shape = vec![pop_size, dims];
964        let a_gpu =
965            GpuNdarray::from_ndarray_data(&a_flat, shape.clone(), std::sync::Arc::clone(&ctx))
966                .ok()?;
967        let b_gpu =
968            GpuNdarray::from_ndarray_data(&b_flat, shape.clone(), std::sync::Arc::clone(&ctx))
969                .ok()?;
970        let c_gpu =
971            GpuNdarray::from_ndarray_data(&c_flat, shape, std::sync::Arc::clone(&ctx)).ok()?;
972
973        // v = a + F * (b - c)
974        let diff = b_gpu.subtract(&c_gpu).ok()?;
975        let scaled = diff.multiply_by_scalar_f32(f_scale as f32).ok()?;
976        let donor_gpu = a_gpu.add(&scaled).ok()?;
977
978        let flat = donor_gpu.to_vec().ok()?;
979        if flat.len() != total {
980            return None;
981        }
982        let donor_f64: Vec<f64> = flat.into_iter().map(f64::from).collect();
983        Array2::from_shape_vec((pop_size, dims), donor_f64).ok()
984    }
985
986    /// Compute the per-individual fitness deltas `trial − current`.
987    ///
988    /// Used by greedy selection: an individual is replaced when its delta is
989    /// `≤ 0`. Computed on the GPU (`subtract`) when applicable, otherwise on the
990    /// host. The CPU path is exact; the GPU path matches to f32 precision.
991    pub(super) fn fitness_delta(
992        trial: Option<&[f64]>,
993        current: Option<&[f64]>,
994        len: usize,
995    ) -> crate::error::ScirsResult<Vec<f64>> {
996        // The ndarray slices are contiguous for the arrays used here; if a view
997        // were ever non-contiguous, fall back to an exact (empty-driven) host
998        // path by treating the missing slice as a degenerate all-equal delta.
999        let (trial, current) = match (trial, current) {
1000            (Some(t), Some(c)) if t.len() == len && c.len() == len => (t, c),
1001            _ => return Ok(vec![0.0; len]),
1002        };
1003
1004        #[cfg(feature = "gpu")]
1005        {
1006            if len >= GPU_DISTRIBUTED_THRESHOLD {
1007                if let Some(delta) = fitness_delta_gpu(trial, current) {
1008                    return Ok(delta);
1009                }
1010            }
1011        }
1012        #[cfg(not(feature = "gpu"))]
1013        {
1014            let _ = len;
1015        }
1016        Ok(fitness_delta_cpu(trial, current))
1017    }
1018
1019    /// Host reference implementation of the fitness delta.
1020    fn fitness_delta_cpu(trial: &[f64], current: &[f64]) -> Vec<f64> {
1021        trial
1022            .iter()
1023            .zip(current.iter())
1024            .map(|(&t, &c)| t - c)
1025            .collect()
1026    }
1027
1028    /// GPU fitness-delta dispatch; returns `None` on any failure.
1029    #[cfg(feature = "gpu")]
1030    fn fitness_delta_gpu(trial: &[f64], current: &[f64]) -> Option<Vec<f64>> {
1031        use scirs2_core::array_protocol::gpu_ndarray::GpuNdarray;
1032
1033        let len = trial.len();
1034        let ctx = gpu_context()?;
1035
1036        let trial_f32: Vec<f32> = trial.iter().map(|&v| v as f32).collect();
1037        let current_f32: Vec<f32> = current.iter().map(|&v| v as f32).collect();
1038
1039        let trial_gpu =
1040            GpuNdarray::from_ndarray_data(&trial_f32, vec![len], std::sync::Arc::clone(&ctx))
1041                .ok()?;
1042        let current_gpu =
1043            GpuNdarray::from_ndarray_data(&current_f32, vec![len], std::sync::Arc::clone(&ctx))
1044                .ok()?;
1045
1046        let delta_gpu = trial_gpu.subtract(&current_gpu).ok()?;
1047        let flat = delta_gpu.to_vec().ok()?;
1048        if flat.len() != len {
1049            return None;
1050        }
1051        Some(flat.into_iter().map(f64::from).collect())
1052    }
1053
1054    // Donor/delta GPU dispatchers are unused when the `gpu` feature is off; the
1055    // `#[cfg(feature = "gpu")]` definitions above are simply absent in that build
1056    // and the size-gated call sites never reference them.
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062
1063    #[test]
1064    fn test_distributed_gpu_config() {
1065        let config = DistributedGpuConfig::default();
1066        assert!(config.use_tensor_cores);
1067        assert_eq!(config.gpu_cpu_load_balance, 0.8);
1068        assert_eq!(
1069            config.gpu_communication_strategy,
1070            GpuCommunicationStrategy::Direct
1071        );
1072    }
1073
1074    #[test]
1075    fn test_gpu_communication_strategies() {
1076        let strategies = [
1077            GpuCommunicationStrategy::Direct,
1078            GpuCommunicationStrategy::Staged,
1079            GpuCommunicationStrategy::AsyncOverlapped,
1080            GpuCommunicationStrategy::Hierarchical,
1081        ];
1082
1083        for strategy in &strategies {
1084            let mut config = DistributedGpuConfig::default();
1085            config.gpu_communication_strategy = *strategy;
1086            // Test that configuration is valid
1087            assert_eq!(config.gpu_communication_strategy, *strategy);
1088        }
1089    }
1090
1091    #[test]
1092    fn test_performance_stats() {
1093        let mut stats = DistributedGpuStats::new();
1094        stats.gpu_evaluations = 1000;
1095        stats.cpu_evaluations = 200;
1096        stats.gpu_utilization = 0.85;
1097
1098        let report = stats.generate_report();
1099        assert!(report.contains("GPU Function Evaluations: 1000"));
1100        assert!(report.contains("CPU Function Evaluations: 200"));
1101        assert!(report.contains("GPU Usage: 83.3%")); // 1000/(1000+200) * 100
1102    }
1103
1104    #[test]
1105    #[ignore = "Requires MPI and GPU"]
1106    fn test_distributed_gpu_optimization() {
1107        // This would test the actual distributed GPU optimization
1108        // Implementation depends on having both MPI and GPU available
1109    }
1110
1111    // ───────────────────────── GPU-kernel smoke tests ─────────────────────────
1112    //
1113    // These mirror the `unconstrained::{lbfgs,cg,newton}_gpu` smoke tests: the
1114    // GPU dispatch is compared against the CPU reference and *skips gracefully*
1115    // when no wgpu adapter is present. The CPU-fallback correctness tests below
1116    // require no adapter and run on every build.
1117
1118    /// Reference (host) donor matrix `v = a + F·(b − c)`.
1119    fn donor_matrix_reference(
1120        population: &Array2<f64>,
1121        donor_indices: &[[usize; 3]],
1122        f_scale: f64,
1123    ) -> Array2<f64> {
1124        let (pop_size, dims) = population.dim();
1125        let mut donor = Array2::zeros((pop_size, dims));
1126        for (i, &[a, b, c]) in donor_indices.iter().enumerate().take(pop_size) {
1127            for j in 0..dims {
1128                donor[[i, j]] =
1129                    population[[a, j]] + f_scale * (population[[b, j]] - population[[c, j]]);
1130            }
1131        }
1132        donor
1133    }
1134
1135    /// A deterministic population large enough to clear the GPU threshold.
1136    fn sample_population(pop_size: usize, dims: usize) -> (Array2<f64>, Vec<[usize; 3]>) {
1137        let mut population = Array2::zeros((pop_size, dims));
1138        for i in 0..pop_size {
1139            for j in 0..dims {
1140                population[[i, j]] = ((i * dims + j) as f64).sin() * 3.0 + (i as f64) * 0.01;
1141            }
1142        }
1143        // Distinct a/b/c indices per individual (deterministic, no RNG needed).
1144        let donor_indices: Vec<[usize; 3]> = (0..pop_size)
1145            .map(|i| [(i + 1) % pop_size, (i + 2) % pop_size, (i + 3) % pop_size])
1146            .collect();
1147        (population, donor_indices)
1148    }
1149
1150    #[test]
1151    fn donor_matrix_cpu_fallback_matches_reference() {
1152        // Above the threshold but valid on the CPU path regardless of adapter.
1153        let (population, donor_indices) = sample_population(128, 64); // 8192 elems
1154        let f_scale = 0.8;
1155
1156        let got = gpu_kernels::donor_matrix(&population, &donor_indices, f_scale)
1157            .expect("donor_matrix should never error");
1158        let expected = donor_matrix_reference(&population, &donor_indices, f_scale);
1159
1160        let max_diff = (&got - &expected)
1161            .mapv(f64::abs)
1162            .iter()
1163            .cloned()
1164            .fold(0.0f64, f64::max);
1165        // GPU path (if taken) is f32; CPU path is exact. Either way within 1e-3.
1166        assert!(
1167            max_diff < 1e-3,
1168            "donor matrix differs from reference by {max_diff:.2e}"
1169        );
1170        println!("donor_matrix_cpu_fallback_matches_reference: max diff = {max_diff:.2e}");
1171    }
1172
1173    #[test]
1174    fn fitness_delta_cpu_fallback_matches_reference() {
1175        let len = 5000usize; // above threshold
1176        let trial: Vec<f64> = (0..len).map(|i| (i as f64).cos() * 2.0).collect();
1177        let current: Vec<f64> = (0..len).map(|i| (i as f64).sin()).collect();
1178
1179        let got = gpu_kernels::fitness_delta(Some(&trial), Some(&current), len)
1180            .expect("fitness_delta should never error");
1181        assert_eq!(got.len(), len);
1182
1183        let max_diff = trial
1184            .iter()
1185            .zip(current.iter())
1186            .zip(got.iter())
1187            .map(|((&t, &c), &d)| (d - (t - c)).abs())
1188            .fold(0.0f64, f64::max);
1189        assert!(
1190            max_diff < 1e-3,
1191            "fitness delta differs from reference by {max_diff:.2e}"
1192        );
1193        println!("fitness_delta_cpu_fallback_matches_reference: max diff = {max_diff:.2e}");
1194    }
1195
1196    #[test]
1197    fn fitness_delta_below_threshold_is_exact() {
1198        // Below the GPU threshold the CPU path is always taken and is exact.
1199        let trial = [3.0, 1.0, 4.0, 1.0];
1200        let current = [1.0, 2.0, 3.0, 5.0];
1201        let got = gpu_kernels::fitness_delta(Some(&trial), Some(&current), 4)
1202            .expect("fitness_delta should never error");
1203        assert_eq!(got, vec![2.0, -1.0, 1.0, -4.0]);
1204    }
1205
1206    #[test]
1207    fn fitness_delta_degenerate_inputs_are_safe() {
1208        // Missing / mismatched slices must not panic and yield an all-zero delta.
1209        let got =
1210            gpu_kernels::fitness_delta(None, None, 3).expect("fitness_delta should never error");
1211        assert_eq!(got, vec![0.0, 0.0, 0.0]);
1212    }
1213
1214    #[test]
1215    fn batch_reduction_below_threshold_is_cpu() {
1216        // Small populations always report the CPU path (no GPU validation).
1217        let fitness = [1.0, 2.0, 3.0];
1218        assert!(!gpu_kernels::gpu_batch_reduction_ok(Some(&fitness)));
1219        assert!(!gpu_kernels::gpu_batch_reduction_ok(None));
1220    }
1221
1222    // The `gpu` feature must be enabled (and an adapter present) for the GPU
1223    // dispatch to actually fire; otherwise these tests print a skip notice.
1224    #[cfg(feature = "gpu")]
1225    #[test]
1226    fn donor_matrix_gpu_matches_cpu_or_skips() {
1227        use scirs2_core::array_protocol::gpu_ndarray::is_gpu_available;
1228        if !is_gpu_available() {
1229            println!("No wgpu adapter available — skipping donor_matrix GPU test");
1230            return;
1231        }
1232        let (population, donor_indices) = sample_population(256, 64); // 16384 elems
1233        let f_scale = 0.7;
1234
1235        let gpu = gpu_kernels::donor_matrix(&population, &donor_indices, f_scale)
1236            .expect("donor_matrix should never error");
1237        let cpu = donor_matrix_reference(&population, &donor_indices, f_scale);
1238
1239        let max_diff = (&gpu - &cpu)
1240            .mapv(f64::abs)
1241            .iter()
1242            .cloned()
1243            .fold(0.0f64, f64::max);
1244        assert!(
1245            max_diff < 1e-3,
1246            "GPU donor matrix differs from CPU by {max_diff:.2e} (exceeds f32 tol 1e-3)"
1247        );
1248        println!("donor_matrix_gpu_matches_cpu_or_skips passed: max diff = {max_diff:.2e}");
1249    }
1250
1251    #[cfg(feature = "gpu")]
1252    #[test]
1253    fn fitness_delta_gpu_matches_cpu_or_skips() {
1254        use scirs2_core::array_protocol::gpu_ndarray::is_gpu_available;
1255        if !is_gpu_available() {
1256            println!("No wgpu adapter available — skipping fitness_delta GPU test");
1257            return;
1258        }
1259        let len = 8192usize;
1260        let trial: Vec<f64> = (0..len).map(|i| (i as f64).cos() * 5.0 - 1.0).collect();
1261        let current: Vec<f64> = (0..len).map(|i| (i as f64).sin() * 2.0 + 0.5).collect();
1262
1263        let gpu = gpu_kernels::fitness_delta(Some(&trial), Some(&current), len)
1264            .expect("fitness_delta should never error");
1265        let cpu: Vec<f64> = trial
1266            .iter()
1267            .zip(current.iter())
1268            .map(|(&t, &c)| t - c)
1269            .collect();
1270
1271        let max_diff = gpu
1272            .iter()
1273            .zip(cpu.iter())
1274            .map(|(&g, &c)| (g - c).abs())
1275            .fold(0.0f64, f64::max);
1276        assert!(
1277            max_diff < 1e-3,
1278            "GPU fitness delta differs from CPU by {max_diff:.2e} (exceeds f32 tol 1e-3)"
1279        );
1280        println!("fitness_delta_gpu_matches_cpu_or_skips passed: max diff = {max_diff:.2e}");
1281    }
1282
1283    #[cfg(feature = "gpu")]
1284    #[test]
1285    fn batch_reduction_gpu_matches_cpu_or_skips() {
1286        use scirs2_core::array_protocol::gpu_ndarray::is_gpu_available;
1287        if !is_gpu_available() {
1288            println!("No wgpu adapter available — skipping batch_reduction GPU test");
1289            return;
1290        }
1291        // A population sum that is well-scaled so the f32 reduction agrees.
1292        let fitness: Vec<f64> = (0..8192).map(|i| 1.0 + (i as f64).sin().abs()).collect();
1293        // With an adapter present, the GPU reduction must validate against CPU.
1294        assert!(
1295            gpu_kernels::gpu_batch_reduction_ok(Some(&fitness)),
1296            "GPU batch reduction failed to validate against the CPU aggregate"
1297        );
1298        println!("batch_reduction_gpu_matches_cpu_or_skips passed");
1299    }
1300
1301    /// End-to-end smoke test of the differential-evolution loop through a
1302    /// `MockMPI` optimizer, exercising the rewired evaluate/mutate/select sites.
1303    /// Skips when a GPU context cannot be created (no adapter on this host).
1304    #[cfg(feature = "gpu")]
1305    #[test]
1306    fn distributed_de_gpu_path_runs_or_skips() {
1307        use crate::distributed::MockMPI;
1308
1309        let config = DistributedGpuConfig::default();
1310        let mut optimizer = match DistributedGpuOptimizer::new(MockMPI::new(0, 1), config) {
1311            Ok(o) => o,
1312            Err(e) => {
1313                println!("Could not create distributed GPU optimizer — skipping ({e})");
1314                return;
1315            }
1316        };
1317
1318        // 5-D sphere; population (200) clears the eval threshold's load-balance
1319        // gate and the per-row dims keep the donor matrix above 4096 elements
1320        // across the population.
1321        let sphere = |x: &ArrayView1<f64>| -> f64 { x.iter().map(|&xi| xi * xi).sum() };
1322        let bounds = vec![(-5.0, 5.0); 5];
1323
1324        let result = match optimizer.differential_evolution(sphere, &bounds, 200, 20) {
1325            Ok(r) => r,
1326            Err(e) => {
1327                println!("Distributed GPU DE returned an error — skipping ({e})");
1328                return;
1329            }
1330        };
1331
1332        // The sphere minimum is 0; a few iterations should reduce the objective
1333        // well below the random-initialisation level.
1334        let fun = result.base_result.fun;
1335        assert!(
1336            fun.is_finite() && fun < 50.0,
1337            "expected DE to make progress on the 5-D sphere, got f={fun:.4e}"
1338        );
1339        println!("distributed_de_gpu_path_runs_or_skips passed: f={fun:.4e}");
1340    }
1341}