use scirs2_core::gpu::{GpuBuffer, GpuContext, GpuDataType, GpuKernelHandle};
use scirs2_core::ndarray::{ArrayBase, Data, DataMut, Dimension};
use scirs2_core::numeric::Float;
use std::marker::PhantomData;
use std::sync::Arc;
use crate::shaders::{CollectiveKernel, WORKGROUP_SIZE};
use crate::GpuOptimError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SyncStrategy {
RingAllReduce,
TreeAllReduce,
HierarchicalAllReduce,
PipelineParallel,
}
#[derive(Debug, Clone)]
pub struct MultiGpuConfig {
pub num_gpus: usize,
pub rank: usize,
pub sync_strategy: SyncStrategy,
pub gradient_compression: bool,
pub compression_ratio: f32,
pub local_group_size: usize,
pub adaptive_communication: bool,
pub bandwidth_monitor_interval: usize,
pub async_param_updates: bool,
pub communication_timeout_ms: u64,
pub error_correction: bool,
pub pipeline_depth: usize,
}
impl Default for MultiGpuConfig {
fn default() -> Self {
Self {
num_gpus: 1,
rank: 0,
sync_strategy: SyncStrategy::RingAllReduce,
gradient_compression: false,
compression_ratio: 0.1, local_group_size: 4,
adaptive_communication: true,
bandwidth_monitor_interval: 100,
async_param_updates: false,
communication_timeout_ms: 5000,
error_correction: true,
pipeline_depth: 2,
}
}
}
impl MultiGpuConfig {
pub fn validate(&self) -> Result<(), GpuOptimError> {
let invalid =
|what: &str| GpuOptimError::InvalidState(format!("invalid multi-GPU config: {what}"));
if self.num_gpus == 0 {
return Err(invalid("num_gpus must be >= 1"));
}
if self.rank >= self.num_gpus {
return Err(invalid("rank must be < num_gpus"));
}
if self.local_group_size == 0 {
return Err(invalid("local_group_size must be >= 1"));
}
if self.pipeline_depth == 0 {
return Err(invalid("pipeline_depth must be >= 1"));
}
if self.gradient_compression
&& !(self.compression_ratio.is_finite()
&& self.compression_ratio > 0.0
&& self.compression_ratio <= 1.0)
{
return Err(invalid("compression_ratio must be finite and in (0, 1]"));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CommunicationPerformanceMonitor {
total_comm_time_us: u64,
total_data_bytes: u64,
comm_operations: usize,
bandwidth_history: std::collections::VecDeque<f64>,
strategy_performance: std::collections::HashMap<SyncStrategy, StrategyPerformanceMetrics>,
}
impl CommunicationPerformanceMonitor {
fn new() -> Self {
Self {
total_comm_time_us: 0,
total_data_bytes: 0,
comm_operations: 0,
bandwidth_history: std::collections::VecDeque::with_capacity(1000),
strategy_performance: std::collections::HashMap::new(),
}
}
fn record_communication(
&mut self,
strategy: SyncStrategy,
data_bytes: u64,
timeus: u64,
tensor_size: usize,
) {
let timeus = timeus.max(1);
self.total_comm_time_us += timeus;
self.total_data_bytes += data_bytes;
self.comm_operations += 1;
let bandwidth_gb_s = (data_bytes as f64) / (timeus as f64 / 1_000_000.0) / 1e9;
self.bandwidth_history.push_back(bandwidth_gb_s);
if self.bandwidth_history.len() > 1000 {
self.bandwidth_history.pop_front();
}
let metrics = self
.strategy_performance
.entry(strategy)
.or_insert_with(StrategyPerformanceMetrics::new);
metrics.update(bandwidth_gb_s, timeus, tensor_size);
}
fn get_average_bandwidth(&self) -> f64 {
if self.total_comm_time_us == 0 {
0.0
} else {
(self.total_data_bytes as f64) / (self.total_comm_time_us as f64 / 1_000_000.0) / 1e9
}
}
fn get_optimal_strategy(&self, tensorsize: usize) -> SyncStrategy {
let mut best_strategy = SyncStrategy::RingAllReduce;
let mut best_score = 0.0;
for (strategy, metrics) in &self.strategy_performance {
let score = metrics.calculate_score(tensorsize);
if score > best_score {
best_score = score;
best_strategy = *strategy;
}
}
best_strategy
}
}
#[derive(Debug, Clone)]
struct StrategyPerformanceMetrics {
bandwidth_samples: std::collections::VecDeque<f64>,
latency_samples: std::collections::VecDeque<u64>,
tensor_sizes: std::collections::VecDeque<usize>,
efficiency_score: f64,
}
impl StrategyPerformanceMetrics {
fn new() -> Self {
Self {
bandwidth_samples: std::collections::VecDeque::with_capacity(100),
latency_samples: std::collections::VecDeque::with_capacity(100),
tensor_sizes: std::collections::VecDeque::with_capacity(100),
efficiency_score: 0.0,
}
}
fn update(&mut self, bandwidth_gb_s: f64, latencyus: u64, tensor_size: usize) {
self.bandwidth_samples.push_back(bandwidth_gb_s);
self.latency_samples.push_back(latencyus);
self.tensor_sizes.push_back(tensor_size);
if self.bandwidth_samples.len() > 100 {
self.bandwidth_samples.pop_front();
self.latency_samples.pop_front();
self.tensor_sizes.pop_front();
}
let avg_bandwidth =
self.bandwidth_samples.iter().sum::<f64>() / self.bandwidth_samples.len() as f64;
let avg_latency =
self.latency_samples.iter().sum::<u64>() as f64 / self.latency_samples.len() as f64;
self.efficiency_score = avg_bandwidth / (avg_latency / 1000.0); }
fn calculate_score(&self, tensorsize: usize) -> f64 {
let size_factor = if tensorsize > 1000000 { 2.0 } else { 1.0 };
let has_comparable_history = self.tensor_sizes.is_empty()
|| self.tensor_sizes.iter().any(|&recorded| {
let (small, large) = if recorded <= tensorsize {
(recorded.max(1), tensorsize.max(1))
} else {
(tensorsize.max(1), recorded)
};
large <= small * 10
});
let relevance = if has_comparable_history { 1.0 } else { 0.5 };
self.efficiency_score * size_factor * relevance
}
}
#[derive(Debug)]
pub struct AdaptiveCommunicationSelector {
current_strategy: SyncStrategy,
switch_cooldown: usize,
last_switch_step: usize,
#[allow(dead_code)]
evaluation_window: usize,
performance_threshold: f64,
}
impl AdaptiveCommunicationSelector {
fn new() -> Self {
Self {
current_strategy: SyncStrategy::RingAllReduce,
switch_cooldown: 50,
last_switch_step: 0,
evaluation_window: 20,
performance_threshold: 1.2, }
}
fn should_evaluate_strategy(&self, currentstep: usize) -> bool {
currentstep - self.last_switch_step >= self.switch_cooldown
}
fn evaluate_and_switch(
&mut self,
monitor: &CommunicationPerformanceMonitor,
tensor_size: usize,
current_step: usize,
) -> Option<SyncStrategy> {
if !self.should_evaluate_strategy(current_step) {
return None;
}
let optimal_strategy = monitor.get_optimal_strategy(tensor_size);
if optimal_strategy != self.current_strategy {
if let (Some(current_metrics), Some(optimal_metrics)) = (
monitor.strategy_performance.get(&self.current_strategy),
monitor.strategy_performance.get(&optimal_strategy),
) {
let performance_ratio =
optimal_metrics.efficiency_score / current_metrics.efficiency_score;
if performance_ratio >= self.performance_threshold {
self.current_strategy = optimal_strategy;
self.last_switch_step = current_step;
return Some(optimal_strategy);
}
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct CommunicationPerformanceStats {
pub average_bandwidth_gb_s: f64,
pub total_operations: usize,
pub total_data_transferred_gb: f64,
pub current_strategy: SyncStrategy,
pub pending_async_ops: usize,
pub step_count: usize,
}
fn encode_u32(value: usize) -> Result<f32, GpuOptimError> {
let raw = u32::try_from(value).map_err(|_| {
GpuOptimError::UnsupportedOperation(format!("{value} does not fit in a u32 kernel operand"))
})?;
Ok(f32::from_bits(raw))
}
fn workgroup_count(n: usize) -> Result<u32, GpuOptimError> {
let groups = n.div_ceil(WORKGROUP_SIZE);
u32::try_from(groups).map_err(|_| {
GpuOptimError::UnsupportedOperation(format!(
"{n} elements need {groups} workgroups, which exceeds the u32 dispatch limit"
))
})
}
fn dispatch_local_reduce(
context: &GpuContext,
kernel: &GpuKernelHandle,
host: &[f32],
num_gpus: usize,
) -> Result<Vec<f32>, GpuOptimError> {
let n = host.len();
let hyper = [encode_u32(n)?, encode_u32(num_gpus)?];
let groups = workgroup_count(n)?;
let x_buf = context.create_buffer::<f32>(n);
x_buf.copy_from_host(host)?;
let y_buf = context.create_buffer::<f32>(hyper.len());
y_buf.copy_from_host(&hyper)?;
kernel.set_buffer("x", &x_buf);
kernel.set_buffer("y", &y_buf);
kernel.dispatch([groups, 1, 1]);
let mut out = vec![0.0f32; n];
x_buf.copy_to_host(&mut out)?;
Ok(out)
}
pub struct MultiGpuSync<A: Float + GpuDataType> {
context: Arc<GpuContext>,
config: MultiGpuConfig,
max_param_size: usize,
reduce_kernel: Option<GpuKernelHandle>,
perf_monitor: CommunicationPerformanceMonitor,
adaptive_selector: AdaptiveCommunicationSelector,
step_counter: usize,
_phantom: PhantomData<A>,
}
impl<A: Float + GpuDataType + Send + Sync> MultiGpuSync<A> {
pub fn new(
context: Arc<GpuContext>,
config: MultiGpuConfig,
max_param_size: usize,
) -> Result<Self, GpuOptimError> {
config.validate()?;
let reduce_kernel = match CollectiveKernel::AllReduceMean.source_for(context.backend()) {
Some(source) => Some(context.execute(|compiler| compiler.compile(source))?),
None => None,
};
Ok(Self {
context,
config,
max_param_size,
reduce_kernel,
perf_monitor: CommunicationPerformanceMonitor::new(),
adaptive_selector: AdaptiveCommunicationSelector::new(),
step_counter: 0,
_phantom: PhantomData,
})
}
pub fn sync_gradients<S, D>(
&mut self,
gradients: &mut ArrayBase<S, D>,
) -> Result<(), GpuOptimError>
where
S: DataMut<Elem = A>,
D: Dimension,
{
self.step_counter += 1;
let tensor_size = gradients.len();
let start_time = std::time::Instant::now();
let strategy = if self.config.adaptive_communication {
if let Some(new_strategy) = self.adaptive_selector.evaluate_and_switch(
&self.perf_monitor,
tensor_size,
self.step_counter,
) {
new_strategy
} else {
self.adaptive_selector.current_strategy
}
} else {
self.config.sync_strategy
};
let result = match strategy {
SyncStrategy::RingAllReduce
| SyncStrategy::TreeAllReduce
| SyncStrategy::HierarchicalAllReduce => self.local_reduce(gradients),
SyncStrategy::PipelineParallel => {
if self.config.async_param_updates {
self.pipeline_parallel_async(gradients)
} else {
Err(GpuOptimError::UnsupportedOperation(
"Pipeline parallel requires async updates enabled".to_string(),
))
}
}
};
let elapsed = start_time.elapsed();
let data_bytes = tensor_size * std::mem::size_of::<A>();
self.perf_monitor.record_communication(
strategy,
data_bytes as u64,
elapsed.as_micros() as u64,
tensor_size,
);
if self
.step_counter
.is_multiple_of(self.config.bandwidth_monitor_interval)
{
self.log_performance_statistics();
}
result
}
fn local_reduce<S, D>(&self, gradients: &mut ArrayBase<S, D>) -> Result<(), GpuOptimError>
where
S: DataMut<Elem = A>,
D: Dimension,
{
if self.config.num_gpus > 1 {
return Err(GpuOptimError::UnsupportedOperation(format!(
"all-reduce across {} GPUs needs a cross-device transport (an NCCL/MPI \
equivalent); this build has a single scirs2_core::gpu::GpuContext and no such \
transport, so peer devices' data can never be fetched",
self.config.num_gpus
)));
}
let n = gradients.len();
if n == 0 {
return Ok(());
}
if n > self.max_param_size {
return Err(GpuOptimError::InvalidState(format!(
"gradient tensor has {n} elements, above the {}-element bound this \
MultiGpuSync was constructed with",
self.max_param_size
)));
}
let kernel = self.reduce_kernel.as_ref().ok_or_else(|| {
GpuOptimError::UnsupportedOperation(format!(
"no all-reduce kernel source for backend {}",
self.context.backend()
))
})?;
let host: Vec<f32> = gradients
.iter()
.map(|v| v.to_f32().unwrap_or(0.0))
.collect();
let out = dispatch_local_reduce(&self.context, kernel, &host, 1)?;
write_back(gradients, &out)
}
fn pipeline_parallel_async<S, D>(
&mut self,
gradients: &mut ArrayBase<S, D>,
) -> Result<(), GpuOptimError>
where
S: DataMut<Elem = A>,
D: Dimension,
{
if self.config.num_gpus > 1 {
return Err(GpuOptimError::UnsupportedOperation(format!(
"pipeline-parallel sync across {} GPUs needs a cross-device transport this \
build does not have",
self.config.num_gpus
)));
}
let n = gradients.len();
if n == 0 {
return Ok(());
}
if n > self.max_param_size {
return Err(GpuOptimError::InvalidState(format!(
"gradient tensor has {n} elements, above the {}-element bound this \
MultiGpuSync was constructed with",
self.max_param_size
)));
}
let kernel = self.reduce_kernel.as_ref().ok_or_else(|| {
GpuOptimError::UnsupportedOperation(format!(
"no all-reduce kernel source for backend {}",
self.context.backend()
))
})?;
let host: Vec<f32> = gradients
.iter()
.map(|v| v.to_f32().unwrap_or(0.0))
.collect();
let depth = self.config.pipeline_depth.max(1);
let chunk_size = n.div_ceil(depth).max(1);
let mut chunks: Vec<(usize, usize, GpuBuffer<f32>)> = Vec::with_capacity(depth);
for stage in 0..depth {
let start = stage * chunk_size;
if start >= n {
break;
}
let end = (start + chunk_size).min(n);
let hyper = [encode_u32(end - start)?, encode_u32(1)?];
let x_buf = self.context.create_buffer::<f32>(end - start);
x_buf.copy_from_host(&host[start..end])?;
let y_buf = self.context.create_buffer::<f32>(hyper.len());
y_buf.copy_from_host(&hyper)?;
kernel.set_buffer("x", &x_buf);
kernel.set_buffer("y", &y_buf);
kernel.dispatch_no_wait([workgroup_count(end - start)?, 1, 1]);
chunks.push((start, end, x_buf));
}
self.context.gpu_sync()?;
let mut out = vec![0.0f32; n];
for (start, end, buf) in &chunks {
buf.copy_to_host(&mut out[*start..*end])?;
}
write_back(gradients, &out)
}
fn log_performance_statistics(&self) {
let avg_bandwidth = self.perf_monitor.get_average_bandwidth();
let total_ops = self.perf_monitor.comm_operations;
log::info!(
"Multi-GPU Performance [Step {}]: {:.2} GB/s avg bandwidth, {} ops, current strategy: {:?}",
self.step_counter,
avg_bandwidth,
total_ops,
self.adaptive_selector.current_strategy
);
}
pub fn get_performance_stats(&self) -> CommunicationPerformanceStats {
CommunicationPerformanceStats {
average_bandwidth_gb_s: self.perf_monitor.get_average_bandwidth(),
total_operations: self.perf_monitor.comm_operations,
total_data_transferred_gb: self.perf_monitor.total_data_bytes as f64 / 1e9,
current_strategy: self.adaptive_selector.current_strategy,
pending_async_ops: 0,
step_count: self.step_counter,
}
}
pub fn synchronize_all(&mut self) -> Result<(), GpuOptimError> {
self.context.gpu_sync().map_err(GpuOptimError::from)
}
pub fn compress_gradients<S, D>(
&mut self,
gradients: &ArrayBase<S, D>,
) -> Result<(Vec<A>, Vec<i32>), GpuOptimError>
where
S: Data<Elem = A>,
D: Dimension,
{
let len = gradients.len();
if len == 0 {
return Ok((Vec::new(), Vec::new()));
}
let k = (((len as f64) * (self.config.compression_ratio as f64)).round() as usize)
.clamp(1, len);
let mut indexed: Vec<(usize, A)> = gradients.iter().copied().enumerate().collect();
indexed.sort_by(|(_, a), (_, b)| {
b.abs()
.partial_cmp(&a.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
indexed.truncate(k);
let mut values = Vec::with_capacity(k);
let mut indices = Vec::with_capacity(k);
for (idx, value) in indexed {
values.push(value);
indices.push(idx as i32);
}
Ok((values, indices))
}
}
fn write_back<A, S, D>(array: &mut ArrayBase<S, D>, values: &[f32]) -> Result<(), GpuOptimError>
where
A: Float,
S: DataMut<Elem = A>,
D: Dimension,
{
for (dst, &src) in array.iter_mut().zip(values.iter()) {
*dst = A::from(src).ok_or_else(|| {
GpuOptimError::InvalidState(format!(
"{src} is not representable in the target float type"
))
})?;
}
Ok(())
}
pub struct MultiGpuSetup {
pub contexts: Vec<Arc<GpuContext>>,
pub sync_managers: Vec<MultiGpuSync<f32>>,
}
impl MultiGpuSetup {
pub fn new(num_gpus: usize, max_param_size: usize) -> Result<Self, GpuOptimError> {
let mut reasons = Vec::new();
let mut opened = None;
for backend in crate::optimizers::SUPPORTED_BACKENDS {
match GpuContext::new(backend) {
Ok(context) => {
opened = Some(context);
break;
}
Err(e) => reasons.push(format!("{backend}: {e}")),
}
}
let Some(shared_context) = opened else {
return Err(GpuOptimError::UnsupportedOperation(format!(
"no GPU backend available for multi-GPU setup ({})",
reasons.join("; ")
)));
};
let mut contexts = Vec::with_capacity(num_gpus);
let mut sync_managers = Vec::with_capacity(num_gpus);
let context = Arc::new(shared_context);
for rank in 0..num_gpus {
let config = MultiGpuConfig {
num_gpus,
rank,
..Default::default()
};
let sync_manager = MultiGpuSync::new(context.clone(), config, max_param_size)?;
contexts.push(context.clone());
sync_managers.push(sync_manager);
}
Ok(Self {
contexts,
sync_managers,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::optimizers::SUPPORTED_BACKENDS;
use scirs2_core::ndarray::Array1;
#[test]
fn test_multi_gpu_config_default() {
let config = MultiGpuConfig::default();
assert_eq!(config.num_gpus, 1);
assert_eq!(config.rank, 0);
assert_eq!(config.sync_strategy, SyncStrategy::RingAllReduce);
assert!(!config.gradient_compression);
assert!(config.validate().is_ok());
}
#[test]
fn config_validate_rejects_divide_by_zero_fields() {
let base = MultiGpuConfig::default();
assert!(MultiGpuConfig {
num_gpus: 0,
..base.clone()
}
.validate()
.is_err());
assert!(MultiGpuConfig {
local_group_size: 0,
..base.clone()
}
.validate()
.is_err());
assert!(MultiGpuConfig {
pipeline_depth: 0,
..base.clone()
}
.validate()
.is_err());
assert!(MultiGpuConfig {
rank: 5,
num_gpus: 2,
..base.clone()
}
.validate()
.is_err());
assert!(MultiGpuConfig {
gradient_compression: true,
compression_ratio: 0.0,
..base.clone()
}
.validate()
.is_err());
assert!(MultiGpuConfig {
gradient_compression: true,
compression_ratio: f32::NAN,
..base
}
.validate()
.is_err());
}
#[test]
fn test_sync_strategy_selection() {
let strategies = [
SyncStrategy::RingAllReduce,
SyncStrategy::TreeAllReduce,
SyncStrategy::HierarchicalAllReduce,
SyncStrategy::PipelineParallel,
];
for strategy in &strategies {
let config = MultiGpuConfig {
sync_strategy: *strategy,
..Default::default()
};
assert_eq!(config.sync_strategy, *strategy);
}
}
#[test]
fn test_communication_performance_monitor() {
let mut monitor = CommunicationPerformanceMonitor::new();
monitor.record_communication(SyncStrategy::RingAllReduce, 1000000, 1000, 1000000); monitor.record_communication(SyncStrategy::TreeAllReduce, 2000000, 1000, 1000000);
assert_eq!(monitor.comm_operations, 2);
assert!(monitor.get_average_bandwidth() > 0.0);
let optimal = monitor.get_optimal_strategy(1000000);
assert!(matches!(
optimal,
SyncStrategy::RingAllReduce | SyncStrategy::TreeAllReduce
));
}
#[test]
fn record_communication_clamps_zero_elapsed_time() {
let mut monitor = CommunicationPerformanceMonitor::new();
monitor.record_communication(SyncStrategy::RingAllReduce, 1_000_000, 0, 1_000_000);
let avg = monitor.get_average_bandwidth();
assert!(avg.is_finite(), "average bandwidth was not finite: {avg}");
assert!(avg > 0.0);
assert!(monitor
.bandwidth_history
.back()
.copied()
.unwrap_or(f64::NAN)
.is_finite());
}
#[test]
fn test_adaptive_communication_selector() {
let mut selector = AdaptiveCommunicationSelector::new();
let mut monitor = CommunicationPerformanceMonitor::new();
assert_eq!(selector.current_strategy, SyncStrategy::RingAllReduce);
for _ in 0..10 {
monitor.record_communication(SyncStrategy::TreeAllReduce, 1000000, 500, 1000000);
}
let new_strategy = selector.evaluate_and_switch(&monitor, 1000000, 100);
if let Some(strategy) = new_strategy {
assert_ne!(strategy, SyncStrategy::RingAllReduce);
}
}
#[test]
fn test_multi_gpu_config_extended() {
let config = MultiGpuConfig {
num_gpus: 8,
adaptive_communication: true,
bandwidth_monitor_interval: 50,
async_param_updates: true,
communication_timeout_ms: 1000,
error_correction: true,
pipeline_depth: 4,
..Default::default()
};
assert_eq!(config.num_gpus, 8);
assert!(config.adaptive_communication);
assert_eq!(config.bandwidth_monitor_interval, 50);
assert!(config.async_param_updates);
assert_eq!(config.communication_timeout_ms, 1000);
assert!(config.error_correction);
assert_eq!(config.pipeline_depth, 4);
}
#[test]
fn test_strategy_performance_metrics() {
let mut metrics = StrategyPerformanceMetrics::new();
metrics.update(10.0, 1000, 1000000); metrics.update(15.0, 800, 1000000);
assert!(metrics.efficiency_score > 0.0);
let score = metrics.calculate_score(1000000); assert!(score > 0.0);
}
#[test]
fn test_calculate_score_discounts_unfamiliar_tensor_sizes() {
let mut metrics = StrategyPerformanceMetrics::new();
metrics.update(10.0, 1000, 1_000_000);
metrics.update(10.0, 1000, 1_000_000);
let familiar = metrics.calculate_score(1_000_000);
let unfamiliar = metrics.calculate_score(1_000);
assert!(
unfamiliar < familiar,
"score for an unfamiliar tensor size ({unfamiliar}) should be lower than for a \
size this strategy has a track record at ({familiar})"
);
}
#[test]
fn test_communication_performance_stats() {
let stats = CommunicationPerformanceStats {
average_bandwidth_gb_s: 10.5,
total_operations: 100,
total_data_transferred_gb: 50.0,
current_strategy: SyncStrategy::RingAllReduce,
pending_async_ops: 0,
step_count: 1000,
};
assert_eq!(stats.average_bandwidth_gb_s, 10.5);
assert_eq!(stats.total_operations, 100);
assert_eq!(stats.total_data_transferred_gb, 50.0);
assert_eq!(stats.current_strategy, SyncStrategy::RingAllReduce);
assert_eq!(stats.pending_async_ops, 0);
assert_eq!(stats.step_count, 1000);
}
#[test]
fn compress_gradients_selects_real_top_k() {
let context = match probe_backend() {
Some(backend) => Arc::new(GpuContext::new(backend).expect("backend just probed")),
None => {
eprintln!("SKIP: compress_gradients_selects_real_top_k — no usable GPU backend");
return;
}
};
let config = MultiGpuConfig {
gradient_compression: true,
compression_ratio: 0.25,
..Default::default()
};
let mut sync = MultiGpuSync::<f32>::new(context, config, 1024).expect("construction");
let data = Array1::from(vec![0.1f32, -5.0, 2.0, 0.3, -4.0, 1.0, 0.05, -0.2]);
let (values, indices) = sync.compress_gradients(&data).expect("compression");
assert_eq!(values.len(), 2);
assert_eq!(indices.len(), 2);
let mut got: Vec<(i32, f32)> = indices.into_iter().zip(values).collect();
got.sort_by_key(|(idx, _)| *idx);
assert_eq!(got, vec![(1, -5.0), (4, -4.0)]);
}
#[test]
fn compress_gradients_ratio_never_selects_zero_elements() {
let context = match probe_backend() {
Some(backend) => Arc::new(GpuContext::new(backend).expect("backend just probed")),
None => {
eprintln!(
"SKIP: compress_gradients_ratio_never_selects_zero_elements — no usable GPU backend"
);
return;
}
};
let config = MultiGpuConfig {
gradient_compression: true,
compression_ratio: 0.01, ..Default::default()
};
let mut sync = MultiGpuSync::<f32>::new(context, config, 1024).expect("construction");
let data = Array1::from(vec![1.0f32, 2.0, 3.0, 4.0]);
let (values, _) = sync.compress_gradients(&data).expect("compression");
assert_eq!(
values.len(),
1,
"a nonzero ratio must keep at least one element"
);
}
fn probe_backend() -> Option<scirs2_core::gpu::GpuBackend> {
SUPPORTED_BACKENDS
.into_iter()
.find(|&backend| GpuContext::new(backend).is_ok())
}
#[test]
fn single_device_sync_runs_a_real_kernel_and_is_the_identity() {
let backend = match probe_backend() {
Some(b) => b,
None => {
eprintln!(
"SKIP: single_device_sync_runs_a_real_kernel_and_is_the_identity — no usable GPU backend"
);
return;
}
};
let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
let config = MultiGpuConfig::default(); let mut sync = MultiGpuSync::<f32>::new(context, config, 4096).expect("construction");
for strategy in [
SyncStrategy::RingAllReduce,
SyncStrategy::TreeAllReduce,
SyncStrategy::HierarchicalAllReduce,
] {
sync.config.sync_strategy = strategy;
let original: Array1<f32> =
Array1::from((0..777).map(|i| i as f32 * 0.5 - 10.0).collect::<Vec<_>>());
let mut grads = original.clone();
sync.sync_gradients(&mut grads).unwrap_or_else(|e| {
panic!("{strategy:?}: single-device sync must succeed, got {e}")
});
for (a, b) in original.iter().zip(grads.iter()) {
assert!(
(a - b).abs() < 1e-5,
"{strategy:?}: single-device all-reduce changed the data: {a} -> {b}"
);
}
}
}
#[test]
fn multi_device_sync_is_an_honest_unsupported_error() {
let backend = match probe_backend() {
Some(b) => b,
None => {
eprintln!("SKIP: multi_device_sync_is_an_honest_unsupported_error — no usable GPU backend");
return;
}
};
let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
let config = MultiGpuConfig {
num_gpus: 2,
..Default::default()
};
let mut sync = MultiGpuSync::<f32>::new(context, config, 4096).expect("construction");
let mut grads = Array1::from_elem(16, 1.0f32);
let err = sync
.sync_gradients(&mut grads)
.expect_err("num_gpus > 1 must fail, not silently succeed");
assert!(matches!(err, GpuOptimError::UnsupportedOperation(_)));
}
#[test]
fn pipeline_parallel_covers_every_element_including_the_tail() {
let backend = match probe_backend() {
Some(b) => b,
None => {
eprintln!(
"SKIP: pipeline_parallel_covers_every_element_including_the_tail — no usable GPU backend"
);
return;
}
};
let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
let config = MultiGpuConfig {
sync_strategy: SyncStrategy::PipelineParallel,
async_param_updates: true,
pipeline_depth: 4,
adaptive_communication: false,
..Default::default()
};
let mut sync = MultiGpuSync::<f32>::new(context, config, 4096).expect("construction");
let original: Array1<f32> = Array1::from((0..777).map(|i| i as f32).collect::<Vec<_>>());
let mut grads = original.clone();
sync.sync_gradients(&mut grads).expect("pipeline sync");
for (i, (a, b)) in original.iter().zip(grads.iter()).enumerate() {
assert!(
(a - b).abs() < 1e-5,
"element {i} was dropped or corrupted: {a} -> {b}"
);
}
}
#[test]
fn synchronize_all_waits_on_a_real_fence() {
let backend = match probe_backend() {
Some(b) => b,
None => {
eprintln!("SKIP: synchronize_all_waits_on_a_real_fence — no usable GPU backend");
return;
}
};
let context = Arc::new(GpuContext::new(backend).expect("backend just probed"));
let mut sync = MultiGpuSync::<f32>::new(context, MultiGpuConfig::default(), 1024)
.expect("construction");
assert!(sync.synchronize_all().is_ok());
}
#[test]
fn multi_gpu_setup_opens_a_real_backend_not_the_removed_cuda_one() {
match MultiGpuSetup::new(2, 1024) {
Ok(setup) => {
assert_eq!(setup.contexts.len(), 2);
assert_eq!(setup.sync_managers.len(), 2);
for context in &setup.contexts {
assert_ne!(
context.backend(),
scirs2_core::gpu::GpuBackend::Cuda,
"must never request the CUDA backend scirs2-core 0.6.x always errors on"
);
}
}
Err(e) => {
eprintln!(
"SKIP: multi_gpu_setup_opens_a_real_backend_not_the_removed_cuda_one — {e}"
);
}
}
}
}