Skip to main content

optirs_gpu/
multi_gpu.rs

1// Multi-GPU synchronization support for distributed training
2//
3// # What is real here
4//
5// [`MultiGpuSync`] drives a real, compiled compute shader
6// ([`crate::shaders::CollectiveKernel::AllReduceMean`]) through
7// [`scirs2_core::gpu`] on whatever single device the caller's [`GpuContext`]
8// opened. That is honestly the extent of it: `scirs2-core` 0.6.x exposes one
9// device per context and no cross-device transport (no NCCL/MPI-equivalent),
10// so there is no way for this crate to fetch another physical GPU's data.
11// Every method here is therefore real for `num_gpus == 1` (the only case
12// where "all-reduce" is answerable from local data alone — the answer is the
13// local data itself) and an honest [`GpuOptimError::UnsupportedOperation`]
14// for `num_gpus > 1`, rather than a kernel dispatch to a name nothing
15// registers, or a bare `Ok(())` that quietly did nothing.
16
17use scirs2_core::gpu::{GpuBuffer, GpuContext, GpuDataType, GpuKernelHandle};
18use scirs2_core::ndarray::{ArrayBase, Data, DataMut, Dimension};
19use scirs2_core::numeric::Float;
20use std::marker::PhantomData;
21use std::sync::Arc;
22
23use crate::shaders::{CollectiveKernel, WORKGROUP_SIZE};
24use crate::GpuOptimError;
25
26/// Multi-GPU synchronization strategy
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum SyncStrategy {
29    /// Ring all-reduce (efficient for large tensors)
30    RingAllReduce,
31    /// Tree all-reduce (efficient for small tensors)
32    TreeAllReduce,
33    /// Hierarchical all-reduce (for multi-node setups)
34    HierarchicalAllReduce,
35    /// Pipeline parallel synchronization
36    PipelineParallel,
37}
38
39/// Multi-GPU configuration
40#[derive(Debug, Clone)]
41pub struct MultiGpuConfig {
42    /// Number of GPUs
43    pub num_gpus: usize,
44    /// GPU rank (0-indexed)
45    pub rank: usize,
46    /// Synchronization strategy
47    pub sync_strategy: SyncStrategy,
48    /// Enable gradient compression
49    pub gradient_compression: bool,
50    /// Compression ratio (for top-k compression)
51    pub compression_ratio: f32,
52    /// Local GPU group size (for hierarchical)
53    pub local_group_size: usize,
54    /// Enable adaptive communication optimization
55    pub adaptive_communication: bool,
56    /// Bandwidth monitoring interval (steps)
57    pub bandwidth_monitor_interval: usize,
58    /// Enable asynchronous parameter updates
59    pub async_param_updates: bool,
60    /// Communication timeout (milliseconds)
61    pub communication_timeout_ms: u64,
62    /// Enable error correction for communication
63    pub error_correction: bool,
64    /// Pipeline depth for overlapping computation and communication
65    pub pipeline_depth: usize,
66}
67
68impl Default for MultiGpuConfig {
69    fn default() -> Self {
70        Self {
71            num_gpus: 1,
72            rank: 0,
73            sync_strategy: SyncStrategy::RingAllReduce,
74            gradient_compression: false,
75            compression_ratio: 0.1, // Keep top 10%
76            local_group_size: 4,
77            adaptive_communication: true,
78            bandwidth_monitor_interval: 100,
79            async_param_updates: false,
80            communication_timeout_ms: 5000,
81            error_correction: true,
82            pipeline_depth: 2,
83        }
84    }
85}
86
87impl MultiGpuConfig {
88    /// Check every field that is later used as a divisor or an index bound,
89    /// so a bad config fails here with a clear message instead of panicking
90    /// (division by zero) deep inside a sync call.
91    pub fn validate(&self) -> Result<(), GpuOptimError> {
92        let invalid =
93            |what: &str| GpuOptimError::InvalidState(format!("invalid multi-GPU config: {what}"));
94        if self.num_gpus == 0 {
95            return Err(invalid("num_gpus must be >= 1"));
96        }
97        if self.rank >= self.num_gpus {
98            return Err(invalid("rank must be < num_gpus"));
99        }
100        if self.local_group_size == 0 {
101            return Err(invalid("local_group_size must be >= 1"));
102        }
103        if self.pipeline_depth == 0 {
104            return Err(invalid("pipeline_depth must be >= 1"));
105        }
106        if self.gradient_compression
107            && !(self.compression_ratio.is_finite()
108                && self.compression_ratio > 0.0
109                && self.compression_ratio <= 1.0)
110        {
111            return Err(invalid("compression_ratio must be finite and in (0, 1]"));
112        }
113        Ok(())
114    }
115}
116
117/// Communication performance monitoring
118#[derive(Debug, Clone)]
119pub struct CommunicationPerformanceMonitor {
120    /// Total communication time (microseconds)
121    total_comm_time_us: u64,
122    /// Total data transferred (bytes)
123    total_data_bytes: u64,
124    /// Number of communication operations
125    comm_operations: usize,
126    /// Bandwidth history (GB/s)
127    bandwidth_history: std::collections::VecDeque<f64>,
128    /// Strategy performance tracking
129    strategy_performance: std::collections::HashMap<SyncStrategy, StrategyPerformanceMetrics>,
130}
131
132impl CommunicationPerformanceMonitor {
133    fn new() -> Self {
134        Self {
135            total_comm_time_us: 0,
136            total_data_bytes: 0,
137            comm_operations: 0,
138            bandwidth_history: std::collections::VecDeque::with_capacity(1000),
139            strategy_performance: std::collections::HashMap::new(),
140        }
141    }
142
143    fn record_communication(
144        &mut self,
145        strategy: SyncStrategy,
146        data_bytes: u64,
147        timeus: u64,
148        tensor_size: usize,
149    ) {
150        // A sub-microsecond elapsed time is real (small local ops legitimately
151        // take under 1us), but dividing by it is not: clamp to 1us so the
152        // bandwidth estimate is merely optimistic instead of `inf`/`NaN`.
153        let timeus = timeus.max(1);
154        self.total_comm_time_us += timeus;
155        self.total_data_bytes += data_bytes;
156        self.comm_operations += 1;
157
158        let bandwidth_gb_s = (data_bytes as f64) / (timeus as f64 / 1_000_000.0) / 1e9;
159        self.bandwidth_history.push_back(bandwidth_gb_s);
160
161        if self.bandwidth_history.len() > 1000 {
162            self.bandwidth_history.pop_front();
163        }
164
165        // Update strategy performance
166        let metrics = self
167            .strategy_performance
168            .entry(strategy)
169            .or_insert_with(StrategyPerformanceMetrics::new);
170        metrics.update(bandwidth_gb_s, timeus, tensor_size);
171    }
172
173    fn get_average_bandwidth(&self) -> f64 {
174        if self.total_comm_time_us == 0 {
175            0.0
176        } else {
177            (self.total_data_bytes as f64) / (self.total_comm_time_us as f64 / 1_000_000.0) / 1e9
178        }
179    }
180
181    fn get_optimal_strategy(&self, tensorsize: usize) -> SyncStrategy {
182        let mut best_strategy = SyncStrategy::RingAllReduce;
183        let mut best_score = 0.0;
184
185        for (strategy, metrics) in &self.strategy_performance {
186            let score = metrics.calculate_score(tensorsize);
187            if score > best_score {
188                best_score = score;
189                best_strategy = *strategy;
190            }
191        }
192
193        best_strategy
194    }
195}
196
197/// Performance metrics for a specific synchronization strategy
198#[derive(Debug, Clone)]
199struct StrategyPerformanceMetrics {
200    bandwidth_samples: std::collections::VecDeque<f64>,
201    latency_samples: std::collections::VecDeque<u64>,
202    tensor_sizes: std::collections::VecDeque<usize>,
203    efficiency_score: f64,
204}
205
206impl StrategyPerformanceMetrics {
207    fn new() -> Self {
208        Self {
209            bandwidth_samples: std::collections::VecDeque::with_capacity(100),
210            latency_samples: std::collections::VecDeque::with_capacity(100),
211            tensor_sizes: std::collections::VecDeque::with_capacity(100),
212            efficiency_score: 0.0,
213        }
214    }
215
216    fn update(&mut self, bandwidth_gb_s: f64, latencyus: u64, tensor_size: usize) {
217        self.bandwidth_samples.push_back(bandwidth_gb_s);
218        self.latency_samples.push_back(latencyus);
219        self.tensor_sizes.push_back(tensor_size);
220
221        if self.bandwidth_samples.len() > 100 {
222            self.bandwidth_samples.pop_front();
223            self.latency_samples.pop_front();
224            self.tensor_sizes.pop_front();
225        }
226
227        // Update efficiency score based on recent performance
228        let avg_bandwidth =
229            self.bandwidth_samples.iter().sum::<f64>() / self.bandwidth_samples.len() as f64;
230        let avg_latency =
231            self.latency_samples.iter().sum::<u64>() as f64 / self.latency_samples.len() as f64;
232
233        self.efficiency_score = avg_bandwidth / (avg_latency / 1000.0); // Bandwidth per ms
234    }
235
236    fn calculate_score(&self, tensorsize: usize) -> f64 {
237        // Higher score for better efficiency, adjusted for tensor _size
238        let size_factor = if tensorsize > 1000000 { 2.0 } else { 1.0 }; // Favor strategies for large tensors
239
240        // Trust this strategy's efficiency score less when it has no track
241        // record at a comparable tensor size (within 10x): bandwidth and
242        // latency measured on very differently-sized transfers may not
243        // generalize to this one. A strategy with no history at all is not
244        // penalized further here -- its `efficiency_score` already starts
245        // at 0.0 until `update` has run at least once.
246        let has_comparable_history = self.tensor_sizes.is_empty()
247            || self.tensor_sizes.iter().any(|&recorded| {
248                let (small, large) = if recorded <= tensorsize {
249                    (recorded.max(1), tensorsize.max(1))
250                } else {
251                    (tensorsize.max(1), recorded)
252                };
253                large <= small * 10
254            });
255        let relevance = if has_comparable_history { 1.0 } else { 0.5 };
256
257        self.efficiency_score * size_factor * relevance
258    }
259}
260
261/// Adaptive communication strategy selector
262#[derive(Debug)]
263pub struct AdaptiveCommunicationSelector {
264    /// Current strategy
265    current_strategy: SyncStrategy,
266    /// Strategy switch cooldown (steps)
267    switch_cooldown: usize,
268    /// Last switch step
269    last_switch_step: usize,
270    /// Evaluation window (steps).
271    ///
272    /// Not currently consulted by [`Self::should_evaluate_strategy`] or
273    /// [`Self::evaluate_and_switch`]: `switch_cooldown` (steps since the
274    /// last switch) is the only gate implemented today. Whether
275    /// `evaluation_window` should instead gate how often a potential
276    /// switch is *checked for* (independent of `switch_cooldown`, which
277    /// gates when a switch may actually happen), or how much recent
278    /// history `evaluate_and_switch` compares (as opposed to each
279    /// strategy's full rolling `efficiency_score`), is a scheduling-policy
280    /// decision this lint pass is not making unilaterally -- especially
281    /// since the two fields' default values (50 and 20) are not a clean
282    /// multiple of each other, so guessing the intended relationship risks
283    /// getting it wrong. Recorded as a finding rather than force-wired.
284    #[allow(dead_code)]
285    evaluation_window: usize,
286    /// Performance threshold for strategy switching
287    performance_threshold: f64,
288}
289
290impl AdaptiveCommunicationSelector {
291    fn new() -> Self {
292        Self {
293            current_strategy: SyncStrategy::RingAllReduce,
294            switch_cooldown: 50,
295            last_switch_step: 0,
296            evaluation_window: 20,
297            performance_threshold: 1.2, // 20% improvement required
298        }
299    }
300
301    fn should_evaluate_strategy(&self, currentstep: usize) -> bool {
302        currentstep - self.last_switch_step >= self.switch_cooldown
303    }
304
305    fn evaluate_and_switch(
306        &mut self,
307        monitor: &CommunicationPerformanceMonitor,
308        tensor_size: usize,
309        current_step: usize,
310    ) -> Option<SyncStrategy> {
311        if !self.should_evaluate_strategy(current_step) {
312            return None;
313        }
314
315        let optimal_strategy = monitor.get_optimal_strategy(tensor_size);
316
317        if optimal_strategy != self.current_strategy {
318            // Check if the switch is worth it based on performance threshold
319            if let (Some(current_metrics), Some(optimal_metrics)) = (
320                monitor.strategy_performance.get(&self.current_strategy),
321                monitor.strategy_performance.get(&optimal_strategy),
322            ) {
323                let performance_ratio =
324                    optimal_metrics.efficiency_score / current_metrics.efficiency_score;
325
326                if performance_ratio >= self.performance_threshold {
327                    self.current_strategy = optimal_strategy;
328                    self.last_switch_step = current_step;
329                    return Some(optimal_strategy);
330                }
331            }
332        }
333
334        None
335    }
336}
337
338/// Communication performance statistics snapshot
339#[derive(Debug, Clone)]
340pub struct CommunicationPerformanceStats {
341    pub average_bandwidth_gb_s: f64,
342    pub total_operations: usize,
343    pub total_data_transferred_gb: f64,
344    pub current_strategy: SyncStrategy,
345    /// Always `0`: this build has no asynchronous collective machinery (see
346    /// the module docs). The field is kept so callers that already match on
347    /// this struct do not need to change; it is not a rounded-down real count.
348    pub pending_async_ops: usize,
349    pub step_count: usize,
350}
351
352/// Encode a `usize` element count into an `f32` slot bit-for-bit, recovered on
353/// the device with `bitcast<u32>` / `as_type<uint>`.
354fn encode_u32(value: usize) -> Result<f32, GpuOptimError> {
355    let raw = u32::try_from(value).map_err(|_| {
356        GpuOptimError::UnsupportedOperation(format!("{value} does not fit in a u32 kernel operand"))
357    })?;
358    Ok(f32::from_bits(raw))
359}
360
361/// Number of `WORKGROUP_SIZE`-wide workgroups needed to cover `n` elements.
362fn workgroup_count(n: usize) -> Result<u32, GpuOptimError> {
363    let groups = n.div_ceil(WORKGROUP_SIZE);
364    u32::try_from(groups).map_err(|_| {
365        GpuOptimError::UnsupportedOperation(format!(
366            "{n} elements need {groups} workgroups, which exceeds the u32 dispatch limit"
367        ))
368    })
369}
370
371/// Dispatch the real all-reduce-mean kernel over `data[range]`, in place.
372///
373/// Works for any `A: Float`, not just `f32`: the host round-trips through an
374/// `f32` buffer (the shaders are `f32`-only, matching every other kernel this
375/// crate ships) via the same numeric conversion used throughout this crate
376/// rather than a byte reinterpret, so it is correct for `A = f64` too, just
377/// rounded through `f32` precision.
378fn dispatch_local_reduce(
379    context: &GpuContext,
380    kernel: &GpuKernelHandle,
381    host: &[f32],
382    num_gpus: usize,
383) -> Result<Vec<f32>, GpuOptimError> {
384    let n = host.len();
385    let hyper = [encode_u32(n)?, encode_u32(num_gpus)?];
386    let groups = workgroup_count(n)?;
387
388    let x_buf = context.create_buffer::<f32>(n);
389    x_buf.copy_from_host(host)?;
390    let y_buf = context.create_buffer::<f32>(hyper.len());
391    y_buf.copy_from_host(&hyper)?;
392
393    kernel.set_buffer("x", &x_buf);
394    kernel.set_buffer("y", &y_buf);
395    kernel.dispatch([groups, 1, 1]);
396
397    let mut out = vec![0.0f32; n];
398    x_buf.copy_to_host(&mut out)?;
399    Ok(out)
400}
401
402/// Multi-GPU synchronization manager
403pub struct MultiGpuSync<A: Float + GpuDataType> {
404    /// GPU context
405    context: Arc<GpuContext>,
406    /// Configuration
407    config: MultiGpuConfig,
408    /// Upper bound on the number of elements a single sync call will accept.
409    max_param_size: usize,
410    /// The real local-reduction kernel, compiled once for the context's
411    /// backend. `None` when that backend has no shader source for it (every
412    /// backend this crate ships kernels for is `Wgpu`/`Metal`); every method
413    /// that would need it then returns an honest `UnsupportedOperation`
414    /// instead of dereferencing a handle that was never created.
415    reduce_kernel: Option<GpuKernelHandle>,
416    /// Communication performance monitor
417    perf_monitor: CommunicationPerformanceMonitor,
418    /// Adaptive strategy selector
419    adaptive_selector: AdaptiveCommunicationSelector,
420    /// Step counter for monitoring
421    step_counter: usize,
422    /// Phantom data for type parameter
423    _phantom: PhantomData<A>,
424}
425
426impl<A: Float + GpuDataType + Send + Sync> MultiGpuSync<A> {
427    /// Create a new multi-GPU synchronization manager.
428    ///
429    /// `max_param_size` bounds how large a single tensor `sync_gradients` (and
430    /// friends) will accept; it is a resource cap the caller opts into, not a
431    /// buffer that gets preallocated.
432    pub fn new(
433        context: Arc<GpuContext>,
434        config: MultiGpuConfig,
435        max_param_size: usize,
436    ) -> Result<Self, GpuOptimError> {
437        config.validate()?;
438
439        let reduce_kernel = match CollectiveKernel::AllReduceMean.source_for(context.backend()) {
440            Some(source) => Some(context.execute(|compiler| compiler.compile(source))?),
441            None => None,
442        };
443
444        Ok(Self {
445            context,
446            config,
447            max_param_size,
448            reduce_kernel,
449            perf_monitor: CommunicationPerformanceMonitor::new(),
450            adaptive_selector: AdaptiveCommunicationSelector::new(),
451            step_counter: 0,
452            _phantom: PhantomData,
453        })
454    }
455
456    /// Synchronize gradients across GPUs
457    pub fn sync_gradients<S, D>(
458        &mut self,
459        gradients: &mut ArrayBase<S, D>,
460    ) -> Result<(), GpuOptimError>
461    where
462        S: DataMut<Elem = A>,
463        D: Dimension,
464    {
465        self.step_counter += 1;
466        let tensor_size = gradients.len();
467        let start_time = std::time::Instant::now();
468
469        // Adaptive strategy selection
470        let strategy = if self.config.adaptive_communication {
471            if let Some(new_strategy) = self.adaptive_selector.evaluate_and_switch(
472                &self.perf_monitor,
473                tensor_size,
474                self.step_counter,
475            ) {
476                new_strategy
477            } else {
478                self.adaptive_selector.current_strategy
479            }
480        } else {
481            self.config.sync_strategy
482        };
483
484        // Execute synchronization. Without a cross-device transport every
485        // topology (ring / tree / hierarchical) answers the single-device
486        // case identically, and every topology is equally unable to serve
487        // `num_gpus > 1` — see the module docs.
488        let result = match strategy {
489            SyncStrategy::RingAllReduce
490            | SyncStrategy::TreeAllReduce
491            | SyncStrategy::HierarchicalAllReduce => self.local_reduce(gradients),
492            SyncStrategy::PipelineParallel => {
493                if self.config.async_param_updates {
494                    self.pipeline_parallel_async(gradients)
495                } else {
496                    Err(GpuOptimError::UnsupportedOperation(
497                        "Pipeline parallel requires async updates enabled".to_string(),
498                    ))
499                }
500            }
501        };
502
503        // Record performance
504        let elapsed = start_time.elapsed();
505        let data_bytes = tensor_size * std::mem::size_of::<A>();
506
507        self.perf_monitor.record_communication(
508            strategy,
509            data_bytes as u64,
510            elapsed.as_micros() as u64,
511            tensor_size,
512        );
513
514        // Periodic monitoring output
515        if self
516            .step_counter
517            .is_multiple_of(self.config.bandwidth_monitor_interval)
518        {
519            self.log_performance_statistics();
520        }
521
522        result
523    }
524
525    /// The one real operation every collective strategy reduces to on a
526    /// single device: divide the local buffer by the replica count. For
527    /// `num_gpus == 1` this is the exact all-reduce-mean answer — there is
528    /// nothing else to sum — computed for real via a compiled compute
529    /// shader. For `num_gpus > 1` this honestly refuses: there is no
530    /// transport in this build to fetch the other replicas' data.
531    fn local_reduce<S, D>(&self, gradients: &mut ArrayBase<S, D>) -> Result<(), GpuOptimError>
532    where
533        S: DataMut<Elem = A>,
534        D: Dimension,
535    {
536        if self.config.num_gpus > 1 {
537            return Err(GpuOptimError::UnsupportedOperation(format!(
538                "all-reduce across {} GPUs needs a cross-device transport (an NCCL/MPI \
539                 equivalent); this build has a single scirs2_core::gpu::GpuContext and no such \
540                 transport, so peer devices' data can never be fetched",
541                self.config.num_gpus
542            )));
543        }
544        let n = gradients.len();
545        if n == 0 {
546            return Ok(());
547        }
548        if n > self.max_param_size {
549            return Err(GpuOptimError::InvalidState(format!(
550                "gradient tensor has {n} elements, above the {}-element bound this \
551                 MultiGpuSync was constructed with",
552                self.max_param_size
553            )));
554        }
555        let kernel = self.reduce_kernel.as_ref().ok_or_else(|| {
556            GpuOptimError::UnsupportedOperation(format!(
557                "no all-reduce kernel source for backend {}",
558                self.context.backend()
559            ))
560        })?;
561
562        let host: Vec<f32> = gradients
563            .iter()
564            .map(|v| v.to_f32().unwrap_or(0.0))
565            .collect();
566        let out = dispatch_local_reduce(&self.context, kernel, &host, 1)?;
567        write_back(gradients, &out)
568    }
569
570    /// Pipeline-parallel synchronization.
571    ///
572    /// Splits the tensor into [`MultiGpuConfig::pipeline_depth`] chunks and
573    /// submits one real dispatch per chunk with
574    /// [`GpuKernelHandle::dispatch_no_wait`], then waits for the whole batch
575    /// with one [`GpuContext::gpu_sync`]. That is genuine command-queue
576    /// overlap — what "pipelining" means at the hardware level — and it does
577    /// not require a second physical device to be real. `num_gpus > 1` is
578    /// still an honest error for the same reason as [`Self::local_reduce`].
579    fn pipeline_parallel_async<S, D>(
580        &mut self,
581        gradients: &mut ArrayBase<S, D>,
582    ) -> Result<(), GpuOptimError>
583    where
584        S: DataMut<Elem = A>,
585        D: Dimension,
586    {
587        if self.config.num_gpus > 1 {
588            return Err(GpuOptimError::UnsupportedOperation(format!(
589                "pipeline-parallel sync across {} GPUs needs a cross-device transport this \
590                 build does not have",
591                self.config.num_gpus
592            )));
593        }
594        let n = gradients.len();
595        if n == 0 {
596            return Ok(());
597        }
598        if n > self.max_param_size {
599            return Err(GpuOptimError::InvalidState(format!(
600                "gradient tensor has {n} elements, above the {}-element bound this \
601                 MultiGpuSync was constructed with",
602                self.max_param_size
603            )));
604        }
605        let kernel = self.reduce_kernel.as_ref().ok_or_else(|| {
606            GpuOptimError::UnsupportedOperation(format!(
607                "no all-reduce kernel source for backend {}",
608                self.context.backend()
609            ))
610        })?;
611
612        let host: Vec<f32> = gradients
613            .iter()
614            .map(|v| v.to_f32().unwrap_or(0.0))
615            .collect();
616        let depth = self.config.pipeline_depth.max(1);
617        // `div_ceil` so the tail is never dropped: the last chunk absorbs
618        // whatever remainder `n` does not divide evenly by `depth`.
619        let chunk_size = n.div_ceil(depth).max(1);
620
621        let mut chunks: Vec<(usize, usize, GpuBuffer<f32>)> = Vec::with_capacity(depth);
622        for stage in 0..depth {
623            let start = stage * chunk_size;
624            if start >= n {
625                break;
626            }
627            let end = (start + chunk_size).min(n);
628            let hyper = [encode_u32(end - start)?, encode_u32(1)?];
629
630            let x_buf = self.context.create_buffer::<f32>(end - start);
631            x_buf.copy_from_host(&host[start..end])?;
632            let y_buf = self.context.create_buffer::<f32>(hyper.len());
633            y_buf.copy_from_host(&hyper)?;
634
635            kernel.set_buffer("x", &x_buf);
636            kernel.set_buffer("y", &y_buf);
637            kernel.dispatch_no_wait([workgroup_count(end - start)?, 1, 1]);
638            chunks.push((start, end, x_buf));
639        }
640
641        // One fence for the whole batch: Metal command queues are FIFO, so
642        // waiting on a buffer submitted after every chunk's guarantees every
643        // chunk has completed (see `GpuContext::gpu_sync` docs).
644        self.context.gpu_sync()?;
645
646        let mut out = vec![0.0f32; n];
647        for (start, end, buf) in &chunks {
648            buf.copy_to_host(&mut out[*start..*end])?;
649        }
650
651        write_back(gradients, &out)
652    }
653
654    /// Log performance statistics
655    fn log_performance_statistics(&self) {
656        let avg_bandwidth = self.perf_monitor.get_average_bandwidth();
657        let total_ops = self.perf_monitor.comm_operations;
658
659        log::info!(
660            "Multi-GPU Performance [Step {}]: {:.2} GB/s avg bandwidth, {} ops, current strategy: {:?}",
661            self.step_counter,
662            avg_bandwidth,
663            total_ops,
664            self.adaptive_selector.current_strategy
665        );
666    }
667
668    /// Get communication performance statistics
669    pub fn get_performance_stats(&self) -> CommunicationPerformanceStats {
670        CommunicationPerformanceStats {
671            average_bandwidth_gb_s: self.perf_monitor.get_average_bandwidth(),
672            total_operations: self.perf_monitor.comm_operations,
673            total_data_transferred_gb: self.perf_monitor.total_data_bytes as f64 / 1e9,
674            current_strategy: self.adaptive_selector.current_strategy,
675            pending_async_ops: 0,
676            step_count: self.step_counter,
677        }
678    }
679
680    /// Wait for every dispatch issued so far to complete.
681    pub fn synchronize_all(&mut self) -> Result<(), GpuOptimError> {
682        self.context.gpu_sync().map_err(GpuOptimError::from)
683    }
684
685    /// Compress gradients for bandwidth optimization with real top-*k*
686    /// (largest-magnitude) selection.
687    ///
688    /// This is host-side selection, not a GPU kernel: choosing the *k* largest
689    /// magnitudes is a sort/partition, not a per-element map, and gains
690    /// nothing from a compute shader at the sizes this crate targets. The
691    /// returned `indices` are into the flattened (`.iter()`-order) tensor.
692    pub fn compress_gradients<S, D>(
693        &mut self,
694        gradients: &ArrayBase<S, D>,
695    ) -> Result<(Vec<A>, Vec<i32>), GpuOptimError>
696    where
697        S: Data<Elem = A>,
698        D: Dimension,
699    {
700        let len = gradients.len();
701        if len == 0 {
702            return Ok((Vec::new(), Vec::new()));
703        }
704        // `k = 0` would silently compress every tensor to nothing; a ratio in
705        // (0, 1] (enforced by `MultiGpuConfig::validate`) always keeps at
706        // least the single largest element.
707        let k = (((len as f64) * (self.config.compression_ratio as f64)).round() as usize)
708            .clamp(1, len);
709
710        let mut indexed: Vec<(usize, A)> = gradients.iter().copied().enumerate().collect();
711        // `Float` gives no `Ord`/`total_cmp`; NaNs sort as equal instead of
712        // panicking the comparator.
713        indexed.sort_by(|(_, a), (_, b)| {
714            b.abs()
715                .partial_cmp(&a.abs())
716                .unwrap_or(std::cmp::Ordering::Equal)
717        });
718        indexed.truncate(k);
719
720        let mut values = Vec::with_capacity(k);
721        let mut indices = Vec::with_capacity(k);
722        for (idx, value) in indexed {
723            values.push(value);
724            indices.push(idx as i32);
725        }
726        Ok((values, indices))
727    }
728}
729
730/// Write a flat `f32` slice back into an array of any layout, converting each
731/// element back to `A` through the same numeric path [`local_reduce`] read it
732/// with (never a byte reinterpret).
733fn write_back<A, S, D>(array: &mut ArrayBase<S, D>, values: &[f32]) -> Result<(), GpuOptimError>
734where
735    A: Float,
736    S: DataMut<Elem = A>,
737    D: Dimension,
738{
739    for (dst, &src) in array.iter_mut().zip(values.iter()) {
740        *dst = A::from(src).ok_or_else(|| {
741            GpuOptimError::InvalidState(format!(
742                "{src} is not representable in the target float type"
743            ))
744        })?;
745    }
746    Ok(())
747}
748
749/// Helper to setup multi-GPU training
750pub struct MultiGpuSetup {
751    /// GPU contexts for each device
752    pub contexts: Vec<Arc<GpuContext>>,
753    /// Synchronization managers
754    pub sync_managers: Vec<MultiGpuSync<f32>>,
755}
756
757impl MultiGpuSetup {
758    /// Initialize multi-GPU setup.
759    ///
760    /// Every logical rank shares the *same* physical device: `scirs2-core`
761    /// 0.6.x has no API to enumerate or address more than one, so there is
762    /// nothing else this constructor could honestly open. Opens the context
763    /// via [`crate::optimizers::SUPPORTED_BACKENDS`] (the backends this
764    /// crate actually ships kernels for), never the removed `Cuda` backend
765    /// that always errors.
766    pub fn new(num_gpus: usize, max_param_size: usize) -> Result<Self, GpuOptimError> {
767        let mut reasons = Vec::new();
768        let mut opened = None;
769        for backend in crate::optimizers::SUPPORTED_BACKENDS {
770            match GpuContext::new(backend) {
771                Ok(context) => {
772                    opened = Some(context);
773                    break;
774                }
775                Err(e) => reasons.push(format!("{backend}: {e}")),
776            }
777        }
778        let Some(shared_context) = opened else {
779            return Err(GpuOptimError::UnsupportedOperation(format!(
780                "no GPU backend available for multi-GPU setup ({})",
781                reasons.join("; ")
782            )));
783        };
784
785        let mut contexts = Vec::with_capacity(num_gpus);
786        let mut sync_managers = Vec::with_capacity(num_gpus);
787        let context = Arc::new(shared_context);
788
789        for rank in 0..num_gpus {
790            let config = MultiGpuConfig {
791                num_gpus,
792                rank,
793                ..Default::default()
794            };
795
796            let sync_manager = MultiGpuSync::new(context.clone(), config, max_param_size)?;
797
798            contexts.push(context.clone());
799            sync_managers.push(sync_manager);
800        }
801
802        Ok(Self {
803            contexts,
804            sync_managers,
805        })
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use crate::optimizers::SUPPORTED_BACKENDS;
813    use scirs2_core::ndarray::Array1;
814
815    #[test]
816    fn test_multi_gpu_config_default() {
817        let config = MultiGpuConfig::default();
818        assert_eq!(config.num_gpus, 1);
819        assert_eq!(config.rank, 0);
820        assert_eq!(config.sync_strategy, SyncStrategy::RingAllReduce);
821        assert!(!config.gradient_compression);
822        assert!(config.validate().is_ok());
823    }
824
825    #[test]
826    fn config_validate_rejects_divide_by_zero_fields() {
827        let base = MultiGpuConfig::default();
828        assert!(MultiGpuConfig {
829            num_gpus: 0,
830            ..base.clone()
831        }
832        .validate()
833        .is_err());
834        assert!(MultiGpuConfig {
835            local_group_size: 0,
836            ..base.clone()
837        }
838        .validate()
839        .is_err());
840        assert!(MultiGpuConfig {
841            pipeline_depth: 0,
842            ..base.clone()
843        }
844        .validate()
845        .is_err());
846        assert!(MultiGpuConfig {
847            rank: 5,
848            num_gpus: 2,
849            ..base.clone()
850        }
851        .validate()
852        .is_err());
853        assert!(MultiGpuConfig {
854            gradient_compression: true,
855            compression_ratio: 0.0,
856            ..base.clone()
857        }
858        .validate()
859        .is_err());
860        assert!(MultiGpuConfig {
861            gradient_compression: true,
862            compression_ratio: f32::NAN,
863            ..base
864        }
865        .validate()
866        .is_err());
867    }
868
869    #[test]
870    fn test_sync_strategy_selection() {
871        let strategies = [
872            SyncStrategy::RingAllReduce,
873            SyncStrategy::TreeAllReduce,
874            SyncStrategy::HierarchicalAllReduce,
875            SyncStrategy::PipelineParallel,
876        ];
877
878        for strategy in &strategies {
879            let config = MultiGpuConfig {
880                sync_strategy: *strategy,
881                ..Default::default()
882            };
883            assert_eq!(config.sync_strategy, *strategy);
884        }
885    }
886
887    #[test]
888    fn test_communication_performance_monitor() {
889        let mut monitor = CommunicationPerformanceMonitor::new();
890
891        // Record some communications
892        monitor.record_communication(SyncStrategy::RingAllReduce, 1000000, 1000, 1000000); // 1GB/s
893        monitor.record_communication(SyncStrategy::TreeAllReduce, 2000000, 1000, 1000000); // 2GB/s
894
895        assert_eq!(monitor.comm_operations, 2);
896        assert!(monitor.get_average_bandwidth() > 0.0);
897
898        // Test strategy performance tracking
899        let optimal = monitor.get_optimal_strategy(1000000);
900        assert!(matches!(
901            optimal,
902            SyncStrategy::RingAllReduce | SyncStrategy::TreeAllReduce
903        ));
904    }
905
906    /// A zero-microsecond sample must not poison the running bandwidth with
907    /// `inf`/`NaN` (regression test for F17).
908    #[test]
909    fn record_communication_clamps_zero_elapsed_time() {
910        let mut monitor = CommunicationPerformanceMonitor::new();
911        monitor.record_communication(SyncStrategy::RingAllReduce, 1_000_000, 0, 1_000_000);
912        let avg = monitor.get_average_bandwidth();
913        assert!(avg.is_finite(), "average bandwidth was not finite: {avg}");
914        assert!(avg > 0.0);
915        assert!(monitor
916            .bandwidth_history
917            .back()
918            .copied()
919            .unwrap_or(f64::NAN)
920            .is_finite());
921    }
922
923    #[test]
924    fn test_adaptive_communication_selector() {
925        let mut selector = AdaptiveCommunicationSelector::new();
926        let mut monitor = CommunicationPerformanceMonitor::new();
927
928        // Initial strategy
929        assert_eq!(selector.current_strategy, SyncStrategy::RingAllReduce);
930
931        // Record better performance for tree all-reduce
932        for _ in 0..10 {
933            monitor.record_communication(SyncStrategy::TreeAllReduce, 1000000, 500, 1000000);
934            // Better bandwidth
935        }
936
937        // Should suggest switching after cooldown period
938        let new_strategy = selector.evaluate_and_switch(&monitor, 1000000, 100);
939
940        // Depending on performance threshold, might suggest a switch
941        if let Some(strategy) = new_strategy {
942            assert_ne!(strategy, SyncStrategy::RingAllReduce);
943        }
944    }
945
946    #[test]
947    fn test_multi_gpu_config_extended() {
948        let config = MultiGpuConfig {
949            num_gpus: 8,
950            adaptive_communication: true,
951            bandwidth_monitor_interval: 50,
952            async_param_updates: true,
953            communication_timeout_ms: 1000,
954            error_correction: true,
955            pipeline_depth: 4,
956            ..Default::default()
957        };
958
959        assert_eq!(config.num_gpus, 8);
960        assert!(config.adaptive_communication);
961        assert_eq!(config.bandwidth_monitor_interval, 50);
962        assert!(config.async_param_updates);
963        assert_eq!(config.communication_timeout_ms, 1000);
964        assert!(config.error_correction);
965        assert_eq!(config.pipeline_depth, 4);
966    }
967
968    #[test]
969    fn test_strategy_performance_metrics() {
970        let mut metrics = StrategyPerformanceMetrics::new();
971
972        metrics.update(10.0, 1000, 1000000); // 10 GB/s, 1ms
973        metrics.update(15.0, 800, 1000000); // 15 GB/s, 0.8ms
974
975        assert!(metrics.efficiency_score > 0.0);
976
977        let score = metrics.calculate_score(1000000); // Large tensor
978        assert!(score > 0.0);
979    }
980
981    /// `calculate_score` must genuinely use recorded tensor sizes (not just
982    /// accept and discard them): a strategy whose entire track record is at
983    /// one scale should be trusted less when scored against a tensor size
984    /// three orders of magnitude away, versus a size close to what it has
985    /// actually proven itself on.
986    #[test]
987    fn test_calculate_score_discounts_unfamiliar_tensor_sizes() {
988        let mut metrics = StrategyPerformanceMetrics::new();
989        metrics.update(10.0, 1000, 1_000_000);
990        metrics.update(10.0, 1000, 1_000_000);
991
992        let familiar = metrics.calculate_score(1_000_000);
993        let unfamiliar = metrics.calculate_score(1_000);
994
995        assert!(
996            unfamiliar < familiar,
997            "score for an unfamiliar tensor size ({unfamiliar}) should be lower than for a \
998             size this strategy has a track record at ({familiar})"
999        );
1000    }
1001
1002    #[test]
1003    fn test_communication_performance_stats() {
1004        let stats = CommunicationPerformanceStats {
1005            average_bandwidth_gb_s: 10.5,
1006            total_operations: 100,
1007            total_data_transferred_gb: 50.0,
1008            current_strategy: SyncStrategy::RingAllReduce,
1009            pending_async_ops: 0,
1010            step_count: 1000,
1011        };
1012
1013        assert_eq!(stats.average_bandwidth_gb_s, 10.5);
1014        assert_eq!(stats.total_operations, 100);
1015        assert_eq!(stats.total_data_transferred_gb, 50.0);
1016        assert_eq!(stats.current_strategy, SyncStrategy::RingAllReduce);
1017        assert_eq!(stats.pending_async_ops, 0);
1018        assert_eq!(stats.step_count, 1000);
1019    }
1020
1021    /// Real top-*k* selection: the returned values must be exactly the *k*
1022    /// largest-magnitude elements (regression test for F12 — this used to
1023    /// unconditionally return zeros).
1024    #[test]
1025    fn compress_gradients_selects_real_top_k() {
1026        let context = match probe_backend() {
1027            Some(backend) => Arc::new(GpuContext::new(backend).expect("backend just probed")),
1028            None => {
1029                eprintln!("SKIP: compress_gradients_selects_real_top_k — no usable GPU backend");
1030                return;
1031            }
1032        };
1033        let config = MultiGpuConfig {
1034            gradient_compression: true,
1035            compression_ratio: 0.25,
1036            ..Default::default()
1037        };
1038        let mut sync = MultiGpuSync::<f32>::new(context, config, 1024).expect("construction");
1039
1040        let data = Array1::from(vec![0.1f32, -5.0, 2.0, 0.3, -4.0, 1.0, 0.05, -0.2]);
1041        let (values, indices) = sync.compress_gradients(&data).expect("compression");
1042
1043        // ratio 0.25 of 8 elements -> k = 2; the two largest magnitudes are
1044        // -5.0 (index 1) and -4.0 (index 4).
1045        assert_eq!(values.len(), 2);
1046        assert_eq!(indices.len(), 2);
1047        let mut got: Vec<(i32, f32)> = indices.into_iter().zip(values).collect();
1048        got.sort_by_key(|(idx, _)| *idx);
1049        assert_eq!(got, vec![(1, -5.0), (4, -4.0)]);
1050    }
1051
1052    #[test]
1053    fn compress_gradients_ratio_never_selects_zero_elements() {
1054        let context = match probe_backend() {
1055            Some(backend) => Arc::new(GpuContext::new(backend).expect("backend just probed")),
1056            None => {
1057                eprintln!(
1058                    "SKIP: compress_gradients_ratio_never_selects_zero_elements — no usable GPU backend"
1059                );
1060                return;
1061            }
1062        };
1063        let config = MultiGpuConfig {
1064            gradient_compression: true,
1065            compression_ratio: 0.01, // rounds to 0 of 4 elements without the clamp
1066            ..Default::default()
1067        };
1068        let mut sync = MultiGpuSync::<f32>::new(context, config, 1024).expect("construction");
1069        let data = Array1::from(vec![1.0f32, 2.0, 3.0, 4.0]);
1070        let (values, _) = sync.compress_gradients(&data).expect("compression");
1071        assert_eq!(
1072            values.len(),
1073            1,
1074            "a nonzero ratio must keep at least one element"
1075        );
1076    }
1077
1078    fn probe_backend() -> Option<scirs2_core::gpu::GpuBackend> {
1079        SUPPORTED_BACKENDS
1080            .into_iter()
1081            .find(|&backend| GpuContext::new(backend).is_ok())
1082    }
1083
1084    /// `MultiGpuSync::new` used to unconditionally fail (F2: it asked the
1085    /// registry for kernel names nothing registers). It must now construct,
1086    /// and a single-device sync must actually run the kernel and leave the
1087    /// data numerically unchanged (dividing one replica by one).
1088    #[test]
1089    fn single_device_sync_runs_a_real_kernel_and_is_the_identity() {
1090        let backend = match probe_backend() {
1091            Some(b) => b,
1092            None => {
1093                eprintln!(
1094                    "SKIP: single_device_sync_runs_a_real_kernel_and_is_the_identity — no usable GPU backend"
1095                );
1096                return;
1097            }
1098        };
1099        let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
1100        let config = MultiGpuConfig::default(); // num_gpus: 1
1101        let mut sync = MultiGpuSync::<f32>::new(context, config, 4096).expect("construction");
1102
1103        for strategy in [
1104            SyncStrategy::RingAllReduce,
1105            SyncStrategy::TreeAllReduce,
1106            SyncStrategy::HierarchicalAllReduce,
1107        ] {
1108            sync.config.sync_strategy = strategy;
1109            let original: Array1<f32> =
1110                Array1::from((0..777).map(|i| i as f32 * 0.5 - 10.0).collect::<Vec<_>>());
1111            let mut grads = original.clone();
1112            sync.sync_gradients(&mut grads).unwrap_or_else(|e| {
1113                panic!("{strategy:?}: single-device sync must succeed, got {e}")
1114            });
1115            for (a, b) in original.iter().zip(grads.iter()) {
1116                assert!(
1117                    (a - b).abs() < 1e-5,
1118                    "{strategy:?}: single-device all-reduce changed the data: {a} -> {b}"
1119                );
1120            }
1121        }
1122    }
1123
1124    /// `num_gpus > 1` must be an explicit, honest error — never a silent
1125    /// `Ok(())` that did nothing (F15) and never a panic (F2/F18).
1126    #[test]
1127    fn multi_device_sync_is_an_honest_unsupported_error() {
1128        let backend = match probe_backend() {
1129            Some(b) => b,
1130            None => {
1131                eprintln!("SKIP: multi_device_sync_is_an_honest_unsupported_error — no usable GPU backend");
1132                return;
1133            }
1134        };
1135        let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
1136        let config = MultiGpuConfig {
1137            num_gpus: 2,
1138            ..Default::default()
1139        };
1140        let mut sync = MultiGpuSync::<f32>::new(context, config, 4096).expect("construction");
1141        let mut grads = Array1::from_elem(16, 1.0f32);
1142        let err = sync
1143            .sync_gradients(&mut grads)
1144            .expect_err("num_gpus > 1 must fail, not silently succeed");
1145        assert!(matches!(err, GpuOptimError::UnsupportedOperation(_)));
1146    }
1147
1148    /// Pipeline-parallel sync with a chunk count that does not evenly divide
1149    /// the tensor length must not drop the tail (regression test for F13).
1150    #[test]
1151    fn pipeline_parallel_covers_every_element_including_the_tail() {
1152        let backend = match probe_backend() {
1153            Some(b) => b,
1154            None => {
1155                eprintln!(
1156                    "SKIP: pipeline_parallel_covers_every_element_including_the_tail — no usable GPU backend"
1157                );
1158                return;
1159            }
1160        };
1161        let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
1162        let config = MultiGpuConfig {
1163            sync_strategy: SyncStrategy::PipelineParallel,
1164            async_param_updates: true,
1165            pipeline_depth: 4,
1166            adaptive_communication: false,
1167            ..Default::default()
1168        };
1169        let mut sync = MultiGpuSync::<f32>::new(context, config, 4096).expect("construction");
1170
1171        // 777 does not divide evenly by 4.
1172        let original: Array1<f32> = Array1::from((0..777).map(|i| i as f32).collect::<Vec<_>>());
1173        let mut grads = original.clone();
1174        sync.sync_gradients(&mut grads).expect("pipeline sync");
1175        for (i, (a, b)) in original.iter().zip(grads.iter()).enumerate() {
1176            assert!(
1177                (a - b).abs() < 1e-5,
1178                "element {i} was dropped or corrupted: {a} -> {b}"
1179            );
1180        }
1181    }
1182
1183    #[test]
1184    fn synchronize_all_waits_on_a_real_fence() {
1185        let backend = match probe_backend() {
1186            Some(b) => b,
1187            None => {
1188                eprintln!("SKIP: synchronize_all_waits_on_a_real_fence — no usable GPU backend");
1189                return;
1190            }
1191        };
1192        let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
1193        let mut sync = MultiGpuSync::<f32>::new(context, MultiGpuConfig::default(), 1024)
1194            .expect("construction");
1195        assert!(sync.synchronize_all().is_ok());
1196    }
1197
1198    #[test]
1199    fn multi_gpu_setup_opens_a_real_backend_not_the_removed_cuda_one() {
1200        match MultiGpuSetup::new(2, 1024) {
1201            Ok(setup) => {
1202                assert_eq!(setup.contexts.len(), 2);
1203                assert_eq!(setup.sync_managers.len(), 2);
1204                for context in &setup.contexts {
1205                    assert_ne!(
1206                        context.backend(),
1207                        scirs2_core::gpu::GpuBackend::Cuda,
1208                        "must never request the CUDA backend scirs2-core 0.6.x always errors on"
1209                    );
1210                }
1211            }
1212            Err(e) => {
1213                // Legitimate on a headless machine with no GPU adapter at all.
1214                eprintln!(
1215                    "SKIP: multi_gpu_setup_opens_a_real_backend_not_the_removed_cuda_one — {e}"
1216                );
1217            }
1218        }
1219    }
1220}