Skip to main content

oxigdal_gpu/
multi_gpu.rs

1//! Multi-GPU support for distributed GPU computing.
2//!
3//! This module provides infrastructure for managing multiple GPUs,
4//! distributing work across devices, and handling inter-GPU data transfers.
5
6use crate::context::{GpuContext, GpuContextConfig};
7use crate::error::{GpuError, GpuResult};
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10use tracing::{debug, info, warn};
11use wgpu::{Adapter, AdapterInfo, Backend, Backends, BufferUsages, Instance, PollType};
12
13/// Multi-GPU configuration.
14#[derive(Debug, Clone)]
15pub struct MultiGpuConfig {
16    /// Backends to search for GPUs.
17    pub backends: Backends,
18    /// Minimum number of GPUs required.
19    pub min_devices: usize,
20    /// Maximum number of GPUs to use.
21    pub max_devices: usize,
22    /// Enable automatic load balancing.
23    pub auto_load_balance: bool,
24    /// Enable peer-to-peer transfers (if supported).
25    pub enable_p2p: bool,
26}
27
28impl Default for MultiGpuConfig {
29    fn default() -> Self {
30        Self {
31            backends: Backends::all(),
32            min_devices: 1,
33            max_devices: 8,
34            auto_load_balance: true,
35            enable_p2p: false,
36        }
37    }
38}
39
40/// Information about a GPU device.
41#[derive(Debug, Clone)]
42pub struct GpuDeviceInfo {
43    /// Device index.
44    pub index: usize,
45    /// Adapter information.
46    pub adapter_info: AdapterInfo,
47    /// Backend type.
48    pub backend: Backend,
49    /// Estimated VRAM in bytes (if available).
50    pub vram_bytes: Option<u64>,
51    /// Device is currently active.
52    pub active: bool,
53}
54
55impl GpuDeviceInfo {
56    /// Get a human-readable description.
57    pub fn description(&self) -> String {
58        format!(
59            "GPU {} : {} ({:?})",
60            self.index, self.adapter_info.name, self.backend
61        )
62    }
63}
64
65/// Multi-GPU manager for coordinating multiple devices.
66pub struct MultiGpuManager {
67    /// Available GPU contexts.
68    devices: Vec<Arc<GpuContext>>,
69    /// Device information.
70    device_info: Vec<GpuDeviceInfo>,
71    /// Configuration.
72    config: MultiGpuConfig,
73    /// Load balancing state.
74    load_state: Arc<Mutex<LoadBalanceState>>,
75}
76
77impl MultiGpuManager {
78    /// Create a zero-device manager without touching any wgpu objects.
79    ///
80    /// Used exclusively in tests to construct `InterGpuTransfer` instances
81    /// that exercise the pure-Rust validation paths in `gather` without
82    /// requiring a physical GPU or wgpu backend initialization.
83    #[cfg(test)]
84    pub(crate) fn new_empty_for_testing() -> Self {
85        Self {
86            devices: Vec::new(),
87            device_info: Vec::new(),
88            config: MultiGpuConfig::default(),
89            load_state: Arc::new(Mutex::new(LoadBalanceState::new(0))),
90        }
91    }
92}
93
94#[derive(Debug, Clone)]
95struct LoadBalanceState {
96    /// Number of tasks dispatched to each device.
97    task_counts: HashMap<usize, usize>,
98    /// Estimated workload on each device (arbitrary units).
99    workload: HashMap<usize, f64>,
100}
101
102impl LoadBalanceState {
103    fn new(num_devices: usize) -> Self {
104        let mut task_counts = HashMap::new();
105        let mut workload = HashMap::new();
106
107        for i in 0..num_devices {
108            task_counts.insert(i, 0);
109            workload.insert(i, 0.0);
110        }
111
112        Self {
113            task_counts,
114            workload,
115        }
116    }
117
118    fn select_device(&self) -> usize {
119        // Select device with minimum workload
120        self.workload
121            .iter()
122            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
123            .map(|(idx, _)| *idx)
124            .unwrap_or(0)
125    }
126
127    fn add_task(&mut self, device: usize, workload: f64) {
128        *self.task_counts.entry(device).or_insert(0) += 1;
129        *self.workload.entry(device).or_insert(0.0) += workload;
130    }
131
132    fn complete_task(&mut self, device: usize, workload: f64) {
133        if let Some(count) = self.task_counts.get_mut(&device) {
134            *count = count.saturating_sub(1);
135        }
136        if let Some(load) = self.workload.get_mut(&device) {
137            *load = load.max(workload) - workload;
138        }
139    }
140}
141
142impl MultiGpuManager {
143    /// Create a new multi-GPU manager.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if minimum number of devices cannot be found.
148    pub async fn new(config: MultiGpuConfig) -> GpuResult<Self> {
149        info!("Initializing multi-GPU manager");
150
151        let instance = Instance::new(wgpu::InstanceDescriptor {
152            backends: config.backends,
153            ..wgpu::InstanceDescriptor::new_without_display_handle()
154        });
155
156        // Enumerate all available adapters
157        let adapters = Self::enumerate_adapters(&instance).await;
158
159        if adapters.len() < config.min_devices {
160            return Err(GpuError::no_adapter(format!(
161                "Found {} GPUs, but {} required",
162                adapters.len(),
163                config.min_devices
164            )));
165        }
166
167        let num_devices = adapters.len().min(config.max_devices);
168        info!(
169            "Found {} compatible GPUs, using {}",
170            adapters.len(),
171            num_devices
172        );
173
174        // Create contexts for each device
175        let mut devices = Vec::new();
176        let mut device_info = Vec::new();
177
178        for (index, adapter) in adapters.into_iter().take(num_devices).enumerate() {
179            match Self::create_device_context(adapter, index).await {
180                Ok((context, info)) => {
181                    devices.push(Arc::new(context));
182                    device_info.push(info);
183                    info!(
184                        "Initialized: {}",
185                        device_info
186                            .last()
187                            .map(|i| i.description())
188                            .unwrap_or_default()
189                    );
190                }
191                Err(e) => {
192                    warn!("Failed to initialize GPU {}: {}", index, e);
193                }
194            }
195        }
196
197        if devices.len() < config.min_devices {
198            return Err(GpuError::device_request(format!(
199                "Successfully initialized {} GPUs, but {} required",
200                devices.len(),
201                config.min_devices
202            )));
203        }
204
205        let load_state = Arc::new(Mutex::new(LoadBalanceState::new(devices.len())));
206
207        Ok(Self {
208            devices,
209            device_info,
210            config,
211            load_state,
212        })
213    }
214
215    /// Get the number of available devices.
216    pub fn num_devices(&self) -> usize {
217        self.devices.len()
218    }
219
220    /// Get a specific device context.
221    pub fn device(&self, index: usize) -> Option<&Arc<GpuContext>> {
222        self.devices.get(index)
223    }
224
225    /// Get all device contexts.
226    pub fn devices(&self) -> &[Arc<GpuContext>] {
227        &self.devices
228    }
229
230    /// Get device information.
231    pub fn device_info(&self, index: usize) -> Option<&GpuDeviceInfo> {
232        self.device_info.get(index)
233    }
234
235    /// Get all device information.
236    pub fn all_device_info(&self) -> &[GpuDeviceInfo] {
237        &self.device_info
238    }
239
240    /// Select a device based on load balancing strategy.
241    pub fn select_device(&self) -> usize {
242        if !self.config.auto_load_balance {
243            // Round-robin without load balancing (use simple counter)
244            return 0; // Simplified for now
245        }
246
247        self.load_state
248            .lock()
249            .map(|state| state.select_device())
250            .unwrap_or(0)
251    }
252
253    /// Dispatch work to a device with load balancing.
254    pub fn dispatch<F, T>(&self, workload: f64, f: F) -> GpuResult<T>
255    where
256        F: FnOnce(&GpuContext) -> GpuResult<T>,
257    {
258        let device_idx = self.select_device();
259
260        if let Ok(mut state) = self.load_state.lock() {
261            state.add_task(device_idx, workload);
262        }
263
264        let context = self
265            .devices
266            .get(device_idx)
267            .ok_or_else(|| GpuError::internal("Invalid device index"))?;
268
269        let result = f(context);
270
271        if let Ok(mut state) = self.load_state.lock() {
272            state.complete_task(device_idx, workload);
273        }
274
275        result
276    }
277
278    /// Distribute work across all devices.
279    pub async fn distribute<F, T>(&self, items: Vec<(f64, F)>) -> Vec<GpuResult<T>>
280    where
281        F: FnOnce(&GpuContext) -> GpuResult<T> + Send + 'static,
282        T: Send + 'static,
283    {
284        let mut tasks = Vec::new();
285
286        for (workload, work_fn) in items {
287            let device_idx = self.select_device();
288
289            if let Ok(mut state) = self.load_state.lock() {
290                state.add_task(device_idx, workload);
291            }
292
293            let context = match self.devices.get(device_idx) {
294                Some(ctx) => Arc::clone(ctx),
295                None => continue,
296            };
297
298            let load_state = Arc::clone(&self.load_state);
299
300            let task = tokio::spawn(async move {
301                let result = work_fn(&context);
302
303                if let Ok(mut state) = load_state.lock() {
304                    state.complete_task(device_idx, workload);
305                }
306
307                result
308            });
309
310            tasks.push(task);
311        }
312
313        // Wait for all tasks to complete
314        let mut results = Vec::new();
315        for task in tasks {
316            match task.await {
317                Ok(result) => results.push(result),
318                Err(e) => results.push(Err(GpuError::internal(e.to_string()))),
319            }
320        }
321
322        results
323    }
324
325    /// Get current load statistics.
326    pub fn load_stats(&self) -> HashMap<usize, (usize, f64)> {
327        self.load_state
328            .lock()
329            .map(|state| {
330                let mut stats = HashMap::new();
331                for i in 0..self.devices.len() {
332                    let tasks = *state.task_counts.get(&i).unwrap_or(&0);
333                    let workload = *state.workload.get(&i).unwrap_or(&0.0);
334                    stats.insert(i, (tasks, workload));
335                }
336                stats
337            })
338            .unwrap_or_default()
339    }
340
341    async fn enumerate_adapters(_instance: &Instance) -> Vec<Adapter> {
342        let mut adapters = Vec::new();
343
344        // Try each backend
345        for backend in &[
346            Backends::VULKAN,
347            Backends::METAL,
348            Backends::DX12,
349            Backends::BROWSER_WEBGPU,
350        ] {
351            let backend_instance = Instance::new(wgpu::InstanceDescriptor {
352                backends: *backend,
353                ..wgpu::InstanceDescriptor::new_without_display_handle()
354            });
355
356            if let Ok(adapter) = backend_instance
357                .request_adapter(&wgpu::RequestAdapterOptions {
358                    power_preference: wgpu::PowerPreference::HighPerformance,
359                    force_fallback_adapter: false,
360                    compatible_surface: None,
361                })
362                .await
363            {
364                adapters.push(adapter);
365            }
366        }
367
368        adapters
369    }
370
371    async fn create_device_context(
372        adapter: Adapter,
373        index: usize,
374    ) -> GpuResult<(GpuContext, GpuDeviceInfo)> {
375        let adapter_info = adapter.get_info();
376        let backend = adapter_info.backend;
377
378        // Estimate VRAM (not directly available in WGPU)
379        let vram_bytes = Self::estimate_vram(&adapter_info);
380
381        let config = GpuContextConfig::default().with_label(format!("GPU {}", index));
382
383        let context = GpuContext::with_config(config).await?;
384
385        let info = GpuDeviceInfo {
386            index,
387            adapter_info,
388            backend,
389            vram_bytes,
390            active: true,
391        };
392
393        Ok((context, info))
394    }
395
396    fn estimate_vram(adapter_info: &AdapterInfo) -> Option<u64> {
397        // This is a rough estimation based on device type
398        match adapter_info.device_type {
399            wgpu::DeviceType::DiscreteGpu => Some(8 * 1024 * 1024 * 1024), // 8 GB
400            wgpu::DeviceType::IntegratedGpu => Some(2 * 1024 * 1024 * 1024), // 2 GB
401            wgpu::DeviceType::VirtualGpu => Some(4 * 1024 * 1024 * 1024),  // 4 GB
402            _ => None,
403        }
404    }
405}
406
407/// Inter-GPU data transfer manager.
408///
409/// Maintains a per-device "last submitted buffer" slot so that `gather`
410/// can perform real GPU→CPU readback.  After dispatching work to device `i`,
411/// call [`set_device_buffer`](Self::set_device_buffer) with the result buffer
412/// and its byte-size; then `gather` will DMA-read every registered buffer
413/// back to the host and return the raw bytes.
414pub struct InterGpuTransfer {
415    manager: Arc<MultiGpuManager>,
416    /// Per-device slot: the most-recently registered GPU buffer and its
417    /// byte size, protected by a Mutex so the struct stays `Send + Sync`.
418    per_device_buffers: Vec<Arc<Mutex<Option<(Arc<wgpu::Buffer>, u64)>>>>,
419}
420
421impl InterGpuTransfer {
422    /// Create a new inter-GPU transfer manager.
423    pub fn new(manager: Arc<MultiGpuManager>) -> Self {
424        let num_devices = manager.num_devices();
425        let per_device_buffers = (0..num_devices)
426            .map(|_| Arc::new(Mutex::new(None)))
427            .collect();
428        Self {
429            manager,
430            per_device_buffers,
431        }
432    }
433
434    /// Register the GPU buffer produced by a compute pass on `device_idx`.
435    ///
436    /// The buffer must have at least `BufferUsages::COPY_SRC` so that
437    /// `gather` can issue a copy-to-staging command.
438    ///
439    /// # Errors
440    ///
441    /// Returns [`GpuError::InvalidBuffer`] when `device_idx` is out of range
442    /// or the internal lock is poisoned.
443    pub fn set_device_buffer(
444        &self,
445        device_idx: usize,
446        buffer: Arc<wgpu::Buffer>,
447        size_bytes: u64,
448    ) -> GpuResult<()> {
449        let slot = self
450            .per_device_buffers
451            .get(device_idx)
452            .ok_or_else(|| GpuError::invalid_buffer("device_idx out of range"))?;
453        *slot
454            .lock()
455            .map_err(|_| GpuError::internal("per_device_buffers lock poisoned"))? =
456            Some((buffer, size_bytes));
457        Ok(())
458    }
459
460    /// Copy data between GPUs via the host (staging).
461    ///
462    /// # Errors
463    ///
464    /// Returns an error if transfer fails or devices are invalid.
465    pub async fn copy_buffer(
466        &self,
467        src_device: usize,
468        dst_device: usize,
469        data: &[u8],
470    ) -> GpuResult<()> {
471        let _src_ctx = self
472            .manager
473            .device(src_device)
474            .ok_or_else(|| GpuError::invalid_buffer("Invalid source device"))?;
475
476        let dst_ctx = self
477            .manager
478            .device(dst_device)
479            .ok_or_else(|| GpuError::invalid_buffer("Invalid destination device"))?;
480
481        // Create buffer on destination device
482        let dst_buffer = dst_ctx.device().create_buffer(&wgpu::BufferDescriptor {
483            label: Some("Inter-GPU Transfer"),
484            size: data.len() as u64,
485            usage: BufferUsages::COPY_DST | BufferUsages::STORAGE,
486            mapped_at_creation: false,
487        });
488
489        // Write data to destination
490        dst_ctx.queue().write_buffer(&dst_buffer, 0, data);
491
492        debug!(
493            "Transferred {} bytes from GPU {} to GPU {}",
494            data.len(),
495            src_device,
496            dst_device
497        );
498
499        Ok(())
500    }
501
502    /// Broadcast data to all GPUs.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if any transfer fails.
507    pub async fn broadcast(&self, data: &[u8]) -> GpuResult<()> {
508        for i in 1..self.manager.num_devices() {
509            self.copy_buffer(0, i, data).await?;
510        }
511
512        Ok(())
513    }
514
515    /// Gather data from every source device to `dst_device`.
516    ///
517    /// For each source device `i` (where `i != dst_device`) that has had a
518    /// buffer registered via [`set_device_buffer`](Self::set_device_buffer),
519    /// this method performs a real GPU→CPU readback:
520    ///
521    /// 1. Allocates a CPU-visible *staging* buffer on device `i`.
522    /// 2. Encodes a `copy_buffer_to_buffer` command from the registered source
523    ///    buffer into the staging buffer, then submits it to the device queue.
524    /// 3. Polls the device until the submission has completed (blocking wait).
525    /// 4. Maps the staging buffer for reading, copies the data, and unmaps it.
526    ///
527    /// The returned `Vec<Vec<u8>>` contains one entry per source device
528    /// (ordered by ascending device index, skipping `dst_device`).  Devices
529    /// that have no registered buffer produce an empty `Vec<u8>`.
530    ///
531    /// # Errors
532    ///
533    /// Returns an error when:
534    /// - `dst_device >= num_devices`
535    /// - the wgpu device poll fails
536    /// - the staging buffer cannot be mapped (e.g., the source buffer is missing
537    ///   `COPY_SRC` usage)
538    /// - an internal mutex is poisoned
539    pub async fn gather(&self, dst_device: usize) -> GpuResult<Vec<Vec<u8>>> {
540        // Use `per_device_buffers.len()` as the authoritative device count.
541        // This equals `manager.num_devices()` at construction time and lets
542        // CPU-only tests construct `InterGpuTransfer` with an empty-device
543        // manager while still exercising the validation and readback paths.
544        let num_devices = self.per_device_buffers.len();
545
546        if dst_device >= num_devices {
547            return Err(GpuError::invalid_buffer(format!(
548                "dst_device {} is out of range (num_devices = {})",
549                dst_device, num_devices
550            )));
551        }
552
553        let mut results: Vec<Vec<u8>> = Vec::with_capacity(num_devices.saturating_sub(1));
554
555        for src_idx in 0..num_devices {
556            if src_idx == dst_device {
557                continue;
558            }
559
560            // Retrieve the registered buffer slot for this device.
561            // We do this before consulting the manager so that the `None`
562            // (no-buffer) fast path never needs a real GpuContext.
563            let slot = self
564                .per_device_buffers
565                .get(src_idx)
566                .ok_or_else(|| GpuError::internal("per_device_buffers shorter than num_devices"))?;
567
568            // Extract the buffer handle inside a nested scope so the
569            // `MutexGuard` is dropped before any `.await` point, satisfying
570            // the `clippy::await_holding_lock` lint.
571            let maybe_buffer_info: Option<(Arc<wgpu::Buffer>, u64)> = {
572                let guard = slot
573                    .lock()
574                    .map_err(|_| GpuError::internal("per_device_buffers lock poisoned"))?;
575                guard.as_ref().map(|(buf, sz)| (Arc::clone(buf), *sz))
576                // `guard` drops here — mutex released before any await.
577            };
578
579            // If no buffer has been registered yet, return empty bytes for this device.
580            let (src_buffer, size_bytes) = match maybe_buffer_info {
581                Some(pair) => pair,
582                None => {
583                    debug!(
584                        "gather: device {} has no registered buffer, returning empty slice",
585                        src_idx
586                    );
587                    results.push(Vec::new());
588                    continue;
589                }
590            };
591
592            // Retrieve the GPU context now that we know we have a buffer to
593            // read.  This call is intentionally placed after the `None` check
594            // so that CPU-only tests (where `manager.device()` would return
595            // `None` for every index) never reach this path.
596            let ctx = self
597                .manager
598                .device(src_idx)
599                .ok_or_else(|| GpuError::invalid_buffer("source device context missing"))?;
600
601            // Step 1 – allocate a MAP_READ | COPY_DST staging buffer on device src_idx.
602            // GPU device objects are not directly accessible from GpuContext because the
603            // Arc<Device> is behind a private field, but `GpuContext::device()` returns a
604            // shared reference `&Device`, which is sufficient for buffer creation and polling.
605            let wgpu_device = ctx.device();
606            let wgpu_queue = ctx.queue();
607
608            let staging = wgpu_device.create_buffer(&wgpu::BufferDescriptor {
609                label: Some("gather_staging"),
610                size: size_bytes,
611                usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
612                mapped_at_creation: false,
613            });
614
615            // Step 2 – encode copy_buffer_to_buffer and submit.
616            let mut encoder = wgpu_device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
617                label: Some("gather_copy_encoder"),
618            });
619            encoder.copy_buffer_to_buffer(&src_buffer, 0, &staging, 0, size_bytes);
620            wgpu_queue.submit(std::iter::once(encoder.finish()));
621
622            // Step 3 – poll the device until the copy submission completes.
623            // wgpu 29 uses `PollType::wait_indefinitely()` which blocks until
624            // all submitted work has finished.
625            wgpu_device
626                .poll(PollType::wait_indefinitely())
627                .map_err(|e| GpuError::execution_failed(format!("device poll error: {e:?}")))?;
628
629            // Step 4 – map the staging buffer and read the raw bytes.
630            let (tx, rx) =
631                futures::channel::oneshot::channel::<Result<(), wgpu::BufferAsyncError>>();
632            staging.slice(..).map_async(wgpu::MapMode::Read, move |r| {
633                let _ = tx.send(r);
634            });
635
636            // Poll once more to drive the mapping callback.
637            wgpu_device
638                .poll(PollType::wait_indefinitely())
639                .map_err(|e| {
640                    GpuError::execution_failed(format!("device poll (map) error: {e:?}"))
641                })?;
642
643            rx.await
644                .map_err(|_| GpuError::buffer_mapping("gather: oneshot channel closed"))?
645                .map_err(|e| GpuError::buffer_mapping(format!("gather: map_async failed: {e}")))?;
646
647            let raw_data: Vec<u8> = staging.slice(..).get_mapped_range().to_vec();
648            staging.unmap();
649
650            debug!(
651                "gather: read {} bytes from device {} (staging buffer)",
652                raw_data.len(),
653                src_idx
654            );
655
656            results.push(raw_data);
657        }
658
659        Ok(results)
660    }
661
662    /// Blocking variant of [`gather`](Self::gather).
663    ///
664    /// Wraps the async gather in `pollster::block_on` so that callers in
665    /// synchronous contexts (e.g., tests, CLI tools) do not need an async
666    /// runtime.
667    ///
668    /// # Errors
669    ///
670    /// Propagates any error returned by [`gather`](Self::gather).
671    pub fn gather_blocking(&self, dst_device: usize) -> GpuResult<Vec<Vec<u8>>> {
672        pollster::block_on(self.gather(dst_device))
673    }
674}
675
676/// GPU affinity manager for NUMA-aware scheduling.
677pub struct GpuAffinityManager {
678    /// Device affinity groups (devices that share memory/PCIe bus).
679    affinity_groups: HashMap<usize, Vec<usize>>,
680}
681
682impl GpuAffinityManager {
683    /// Create a new affinity manager.
684    pub fn new() -> Self {
685        Self {
686            affinity_groups: HashMap::new(),
687        }
688    }
689
690    /// Set devices in the same affinity group.
691    pub fn set_affinity_group(&mut self, group_id: usize, devices: Vec<usize>) {
692        self.affinity_groups.insert(group_id, devices);
693    }
694
695    /// Get devices in the same affinity group.
696    pub fn get_affinity_group(&self, device: usize) -> Vec<usize> {
697        for (_, devices) in &self.affinity_groups {
698            if devices.contains(&device) {
699                return devices.clone();
700            }
701        }
702        vec![device]
703    }
704
705    /// Check if two devices are in the same affinity group.
706    pub fn same_affinity(&self, device_a: usize, device_b: usize) -> bool {
707        let group_a = self.get_affinity_group(device_a);
708        group_a.contains(&device_b)
709    }
710
711    /// Get optimal device for data locality.
712    pub fn optimal_device(&self, data_device: usize, available: &[usize]) -> Option<usize> {
713        // Prefer devices in the same affinity group
714        let group = self.get_affinity_group(data_device);
715
716        for device in available {
717            if group.contains(device) {
718                return Some(*device);
719            }
720        }
721
722        // Fall back to any available device
723        available.first().copied()
724    }
725}
726
727impl Default for GpuAffinityManager {
728    fn default() -> Self {
729        Self::new()
730    }
731}
732
733/// Work distribution strategy for multi-GPU processing.
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub enum DistributionStrategy {
736    /// Distribute work evenly across all devices.
737    RoundRobin,
738    /// Distribute based on device capabilities.
739    LoadBalanced,
740    /// Distribute based on data locality.
741    DataLocal,
742    /// Use only the fastest device.
743    SingleDevice,
744}
745
746/// Work distributor for multi-GPU task scheduling.
747pub struct WorkDistributor {
748    manager: Arc<MultiGpuManager>,
749    strategy: DistributionStrategy,
750    affinity: GpuAffinityManager,
751}
752
753impl WorkDistributor {
754    /// Create a new work distributor.
755    pub fn new(manager: Arc<MultiGpuManager>, strategy: DistributionStrategy) -> Self {
756        Self {
757            manager,
758            strategy,
759            affinity: GpuAffinityManager::new(),
760        }
761    }
762
763    /// Set affinity group.
764    pub fn set_affinity_group(&mut self, group_id: usize, devices: Vec<usize>) {
765        self.affinity.set_affinity_group(group_id, devices);
766    }
767
768    /// Distribute work items across GPUs.
769    pub fn distribute_work<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
770        match self.strategy {
771            DistributionStrategy::RoundRobin => self.round_robin(items),
772            DistributionStrategy::LoadBalanced => self.load_balanced(items),
773            DistributionStrategy::DataLocal => self.data_local(items),
774            DistributionStrategy::SingleDevice => self.single_device(items),
775        }
776    }
777
778    fn round_robin<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
779        let num_devices = self.manager.num_devices();
780        let mut device_items: Vec<Vec<T>> = (0..num_devices).map(|_| Vec::new()).collect();
781
782        for (idx, item) in items.into_iter().enumerate() {
783            device_items[idx % num_devices].push(item);
784        }
785
786        device_items
787            .into_iter()
788            .enumerate()
789            .filter(|(_, items)| !items.is_empty())
790            .collect()
791    }
792
793    fn load_balanced<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
794        let stats = self.manager.load_stats();
795        let num_devices = self.manager.num_devices();
796        let items_len = items.len();
797
798        // Calculate weights based on inverse of current load
799        let mut weights: Vec<f64> = (0..num_devices)
800            .map(|i| {
801                let (_, load) = stats.get(&i).unwrap_or(&(0, 0.0));
802                1.0 / (1.0 + load)
803            })
804            .collect();
805
806        // Normalize weights
807        let total: f64 = weights.iter().sum();
808        if total > 0.0 {
809            for w in &mut weights {
810                *w /= total;
811            }
812        }
813
814        // Distribute items based on weights
815        let mut device_items: Vec<Vec<T>> = (0..num_devices).map(|_| Vec::new()).collect();
816
817        for (idx, item) in items.into_iter().enumerate() {
818            let target = (idx as f64 + 0.5) / items_len as f64;
819            let mut device = 0;
820            let mut cumulative = 0.0;
821
822            for (dev, weight) in weights.iter().enumerate() {
823                cumulative += weight;
824                if cumulative >= target {
825                    device = dev;
826                    break;
827                }
828            }
829
830            device_items[device].push(item);
831        }
832
833        device_items
834            .into_iter()
835            .enumerate()
836            .filter(|(_, items)| !items.is_empty())
837            .collect()
838    }
839
840    fn data_local<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
841        // For now, fall back to round-robin
842        // In a real implementation, this would consider data locality
843        self.round_robin(items)
844    }
845
846    fn single_device<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
847        vec![(0, items)]
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn test_multi_gpu_config() {
857        let config = MultiGpuConfig::default();
858        assert_eq!(config.min_devices, 1);
859        assert_eq!(config.max_devices, 8);
860        assert!(config.auto_load_balance);
861    }
862
863    #[test]
864    fn test_load_balance_state() {
865        let mut state = LoadBalanceState::new(3);
866
867        state.add_task(0, 100.0);
868        state.add_task(1, 50.0);
869        state.add_task(2, 75.0);
870
871        // Device 1 should have minimum load
872        assert_eq!(state.select_device(), 1);
873
874        state.complete_task(1, 50.0);
875        assert_eq!(state.select_device(), 1);
876    }
877
878    #[test]
879    fn test_affinity_manager() {
880        let mut manager = GpuAffinityManager::new();
881
882        manager.set_affinity_group(0, vec![0, 1]);
883        manager.set_affinity_group(1, vec![2, 3]);
884
885        assert!(manager.same_affinity(0, 1));
886        assert!(manager.same_affinity(2, 3));
887        assert!(!manager.same_affinity(0, 2));
888
889        let group = manager.get_affinity_group(0);
890        assert_eq!(group, vec![0, 1]);
891    }
892
893    #[test]
894    fn test_distribution_strategy() {
895        assert_eq!(
896            DistributionStrategy::RoundRobin,
897            DistributionStrategy::RoundRobin
898        );
899    }
900
901    // -----------------------------------------------------------------------
902    // gather tests — CPU-level (no GPU hardware required)
903    // -----------------------------------------------------------------------
904    //
905    // The validation logic inside `gather` reads only `self.manager.num_devices()`
906    // and `self.per_device_buffers` — both of which are plain Rust data
907    // structures.  Unfortunately `MultiGpuManager::new` calls `wgpu::Instance::new`
908    // which panics on CI when no GPU backend is compiled in.  The tests below
909    // therefore test:
910    //  (a) `set_device_buffer` returns errors for out-of-range indices
911    //      independently of any wgpu object, and
912    //  (b) the blocking wrapper `gather_blocking` exists and compiles.
913    // Full integration (with real GPUs) is covered by the `#[ignore]` tests.
914
915    /// Helper: build a zero-device `InterGpuTransfer` without touching wgpu.
916    fn zero_device_transfer() -> InterGpuTransfer {
917        InterGpuTransfer {
918            manager: Arc::new(MultiGpuManager::new_empty_for_testing()),
919            per_device_buffers: Vec::new(),
920        }
921    }
922
923    /// Helper: build an `InterGpuTransfer` with `n` device slots but no real
924    /// GPU context behind any of them.  The slots are empty (`None`), so
925    /// `gather` will push `Vec::new()` for each source slot without ever
926    /// calling `manager.device()`.
927    fn n_slot_transfer(n: usize) -> InterGpuTransfer {
928        let per_device_buffers = (0..n).map(|_| Arc::new(Mutex::new(None))).collect();
929        InterGpuTransfer {
930            manager: Arc::new(MultiGpuManager::new_empty_for_testing()),
931            per_device_buffers,
932        }
933    }
934
935    /// `set_device_buffer` with an out-of-range index must return an error.
936    /// This exercises the `per_device_buffers` bounds-check without touching
937    /// any wgpu object.
938    #[test]
939    fn test_set_device_buffer_out_of_range_returns_error() {
940        let transfer = zero_device_transfer();
941        // Provide a dummy Arc<wgpu::Buffer> — wgpu::Buffer is not constructible
942        // without a device, but `set_device_buffer` will error before it stores
943        // the value because the slot Vec is empty.
944        //
945        // We cannot construct a real wgpu::Buffer here, but the function
946        // returns an error before it even touches the buffer, so we just
947        // need to prove the error path is reachable.  Use a zero-length
948        // per_device_buffers and call with device_idx=0.
949        let result = transfer.per_device_buffers.get(0);
950        assert!(result.is_none(), "zero-device transfer must have no slots");
951        // The set_device_buffer call itself requires an Arc<wgpu::Buffer> which
952        // we cannot create without a real device.  We have already verified
953        // that `per_device_buffers` is empty (no slots), meaning
954        // `set_device_buffer(0, ...)` would return Err immediately.
955        // This is a compile-time structural check — no GPU needed.
956    }
957
958    /// `gather_blocking` compiles and, when passed an out-of-range destination
959    /// device, returns an error without touching GPU hardware.
960    #[test]
961    fn test_gather_blocking_available() {
962        // A zero-slot transfer has 0 virtual devices.
963        // dst_device=0 is therefore always out of range.
964        let transfer = zero_device_transfer();
965        let result = transfer.gather_blocking(0);
966        assert!(
967            result.is_err(),
968            "gather_blocking with 0 devices must error on any dst_device"
969        );
970    }
971
972    /// `gather` with no source devices (only device 0, which is the
973    /// destination) must return `Ok(vec![])`.
974    #[test]
975    fn test_gather_no_source_devices_returns_empty() {
976        // Simulate a single-device manager by giving InterGpuTransfer exactly
977        // 1 buffer slot (for device 0).  When gather(dst_device=0) runs, the
978        // inner loop skips device 0 (the only index), producing an empty result.
979        let transfer = n_slot_transfer(1);
980        let result = pollster::block_on(transfer.gather(0));
981        let gathered = result.expect("gather with single-device stub must succeed");
982        assert!(
983            gathered.is_empty(),
984            "no source devices → gather result must be empty"
985        );
986    }
987
988    /// `gather` with `dst_device >= num_devices` must return an error.
989    #[test]
990    fn test_gather_invalid_dst_device_returns_error() {
991        // Single-slot stub: per_device_buffers.len() == 1, dst_device=1 is out of range.
992        let transfer = n_slot_transfer(1);
993        let result = pollster::block_on(transfer.gather(1));
994        assert!(
995            result.is_err(),
996            "dst_device=1 when num_devices=1 must be an error"
997        );
998    }
999
1000    /// GPU-gated integration test: submit a small compute payload, register
1001    /// the result buffer, and verify that gather returns non-empty bytes.
1002    ///
1003    /// Requires real GPU hardware — skipped in CI via `#[ignore]`.
1004    #[ignore]
1005    #[tokio::test]
1006    async fn test_gather_real_readback_returns_nonzero_bytes() {
1007        // We need at least 2 GPUs for a meaningful gather.  If only 1 (or 0)
1008        // is available, the test would be vacuous, so skip gracefully.
1009        let config = MultiGpuConfig {
1010            min_devices: 2,
1011            max_devices: 8,
1012            ..MultiGpuConfig::default()
1013        };
1014        let manager = match MultiGpuManager::new(config).await {
1015            Ok(m) => Arc::new(m),
1016            Err(_) => {
1017                // Fewer than 2 GPUs present — skip.
1018                return;
1019            }
1020        };
1021
1022        let transfer = InterGpuTransfer::new(Arc::clone(&manager));
1023
1024        // On device 1, create a small COPY_SRC | STORAGE buffer populated
1025        // with known bytes and register it for gather.
1026        let src_ctx = manager
1027            .device(1)
1028            .expect("device 1 must exist when num_devices >= 2");
1029
1030        const PAYLOAD: &[u8] = b"OxiGDAL-gather-test-payload-12345678";
1031        let payload_size = PAYLOAD.len() as u64;
1032        // Align to COPY_BUFFER_ALIGNMENT (4).
1033        let aligned = ((payload_size + 3) / 4) * 4;
1034
1035        let src_buffer = Arc::new(src_ctx.device().create_buffer(&wgpu::BufferDescriptor {
1036            label: Some("test_src_buffer"),
1037            size: aligned,
1038            usage: BufferUsages::COPY_SRC | BufferUsages::COPY_DST | BufferUsages::STORAGE,
1039            mapped_at_creation: false,
1040        }));
1041
1042        // Upload payload.
1043        src_ctx.queue().write_buffer(&src_buffer, 0, PAYLOAD);
1044
1045        // Register the buffer.
1046        transfer
1047            .set_device_buffer(1, Arc::clone(&src_buffer), aligned)
1048            .expect("set_device_buffer must succeed for device 1");
1049
1050        // Gather device 1 → device 0.
1051        let gathered = transfer
1052            .gather(0)
1053            .await
1054            .expect("gather must succeed when GPU is available");
1055
1056        assert_eq!(
1057            gathered.len(),
1058            1,
1059            "should have exactly 1 result (device 1 → device 0)"
1060        );
1061        assert!(!gathered[0].is_empty(), "gathered bytes must not be empty");
1062        // The first PAYLOAD.len() bytes must match.
1063        assert_eq!(
1064            &gathered[0][..PAYLOAD.len()],
1065            PAYLOAD,
1066            "gathered payload must match the uploaded data"
1067        );
1068    }
1069}