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                    apply_limit_buckets: false,
362                })
363                .await
364            {
365                adapters.push(adapter);
366            }
367        }
368
369        adapters
370    }
371
372    async fn create_device_context(
373        adapter: Adapter,
374        index: usize,
375    ) -> GpuResult<(GpuContext, GpuDeviceInfo)> {
376        let adapter_info = adapter.get_info();
377        let backend = adapter_info.backend;
378
379        // Estimate VRAM (not directly available in WGPU)
380        let vram_bytes = Self::estimate_vram(&adapter_info);
381
382        let config = GpuContextConfig::default().with_label(format!("GPU {}", index));
383
384        let context = GpuContext::with_config(config).await?;
385
386        let info = GpuDeviceInfo {
387            index,
388            adapter_info,
389            backend,
390            vram_bytes,
391            active: true,
392        };
393
394        Ok((context, info))
395    }
396
397    fn estimate_vram(adapter_info: &AdapterInfo) -> Option<u64> {
398        // This is a rough estimation based on device type
399        match adapter_info.device_type {
400            wgpu::DeviceType::DiscreteGpu => Some(8 * 1024 * 1024 * 1024), // 8 GB
401            wgpu::DeviceType::IntegratedGpu => Some(2 * 1024 * 1024 * 1024), // 2 GB
402            wgpu::DeviceType::VirtualGpu => Some(4 * 1024 * 1024 * 1024),  // 4 GB
403            _ => None,
404        }
405    }
406}
407
408/// Inter-GPU data transfer manager.
409///
410/// Maintains a per-device "last submitted buffer" slot so that `gather`
411/// can perform real GPU→CPU readback.  After dispatching work to device `i`,
412/// call [`set_device_buffer`](Self::set_device_buffer) with the result buffer
413/// and its byte-size; then `gather` will DMA-read every registered buffer
414/// back to the host and return the raw bytes.
415pub struct InterGpuTransfer {
416    manager: Arc<MultiGpuManager>,
417    /// Per-device slot: the most-recently registered GPU buffer and its
418    /// byte size, protected by a Mutex so the struct stays `Send + Sync`.
419    per_device_buffers: Vec<Arc<Mutex<Option<(Arc<wgpu::Buffer>, u64)>>>>,
420}
421
422impl InterGpuTransfer {
423    /// Create a new inter-GPU transfer manager.
424    pub fn new(manager: Arc<MultiGpuManager>) -> Self {
425        let num_devices = manager.num_devices();
426        let per_device_buffers = (0..num_devices)
427            .map(|_| Arc::new(Mutex::new(None)))
428            .collect();
429        Self {
430            manager,
431            per_device_buffers,
432        }
433    }
434
435    /// Register the GPU buffer produced by a compute pass on `device_idx`.
436    ///
437    /// The buffer must have at least `BufferUsages::COPY_SRC` so that
438    /// `gather` can issue a copy-to-staging command.
439    ///
440    /// # Errors
441    ///
442    /// Returns [`GpuError::InvalidBuffer`] when `device_idx` is out of range
443    /// or the internal lock is poisoned.
444    pub fn set_device_buffer(
445        &self,
446        device_idx: usize,
447        buffer: Arc<wgpu::Buffer>,
448        size_bytes: u64,
449    ) -> GpuResult<()> {
450        let slot = self
451            .per_device_buffers
452            .get(device_idx)
453            .ok_or_else(|| GpuError::invalid_buffer("device_idx out of range"))?;
454        *slot
455            .lock()
456            .map_err(|_| GpuError::internal("per_device_buffers lock poisoned"))? =
457            Some((buffer, size_bytes));
458        Ok(())
459    }
460
461    /// Copy data between GPUs via the host (staging).
462    ///
463    /// # Errors
464    ///
465    /// Returns an error if transfer fails or devices are invalid.
466    pub async fn copy_buffer(
467        &self,
468        src_device: usize,
469        dst_device: usize,
470        data: &[u8],
471    ) -> GpuResult<()> {
472        let _src_ctx = self
473            .manager
474            .device(src_device)
475            .ok_or_else(|| GpuError::invalid_buffer("Invalid source device"))?;
476
477        let dst_ctx = self
478            .manager
479            .device(dst_device)
480            .ok_or_else(|| GpuError::invalid_buffer("Invalid destination device"))?;
481
482        // Create buffer on destination device
483        let dst_buffer = dst_ctx.device().create_buffer(&wgpu::BufferDescriptor {
484            label: Some("Inter-GPU Transfer"),
485            size: data.len() as u64,
486            usage: BufferUsages::COPY_DST | BufferUsages::STORAGE,
487            mapped_at_creation: false,
488        });
489
490        // Write data to destination
491        dst_ctx.queue().write_buffer(&dst_buffer, 0, data);
492
493        debug!(
494            "Transferred {} bytes from GPU {} to GPU {}",
495            data.len(),
496            src_device,
497            dst_device
498        );
499
500        Ok(())
501    }
502
503    /// Broadcast data to all GPUs.
504    ///
505    /// # Errors
506    ///
507    /// Returns an error if any transfer fails.
508    pub async fn broadcast(&self, data: &[u8]) -> GpuResult<()> {
509        for i in 1..self.manager.num_devices() {
510            self.copy_buffer(0, i, data).await?;
511        }
512
513        Ok(())
514    }
515
516    /// Gather data from every source device to `dst_device`.
517    ///
518    /// For each source device `i` (where `i != dst_device`) that has had a
519    /// buffer registered via [`set_device_buffer`](Self::set_device_buffer),
520    /// this method performs a real GPU→CPU readback:
521    ///
522    /// 1. Allocates a CPU-visible *staging* buffer on device `i`.
523    /// 2. Encodes a `copy_buffer_to_buffer` command from the registered source
524    ///    buffer into the staging buffer, then submits it to the device queue.
525    /// 3. Polls the device until the submission has completed (blocking wait).
526    /// 4. Maps the staging buffer for reading, copies the data, and unmaps it.
527    ///
528    /// The returned `Vec<Vec<u8>>` contains one entry per source device
529    /// (ordered by ascending device index, skipping `dst_device`).  Devices
530    /// that have no registered buffer produce an empty `Vec<u8>`.
531    ///
532    /// # Errors
533    ///
534    /// Returns an error when:
535    /// - `dst_device >= num_devices`
536    /// - the wgpu device poll fails
537    /// - the staging buffer cannot be mapped (e.g., the source buffer is missing
538    ///   `COPY_SRC` usage)
539    /// - an internal mutex is poisoned
540    pub async fn gather(&self, dst_device: usize) -> GpuResult<Vec<Vec<u8>>> {
541        // Use `per_device_buffers.len()` as the authoritative device count.
542        // This equals `manager.num_devices()` at construction time and lets
543        // CPU-only tests construct `InterGpuTransfer` with an empty-device
544        // manager while still exercising the validation and readback paths.
545        let num_devices = self.per_device_buffers.len();
546
547        if dst_device >= num_devices {
548            return Err(GpuError::invalid_buffer(format!(
549                "dst_device {} is out of range (num_devices = {})",
550                dst_device, num_devices
551            )));
552        }
553
554        let mut results: Vec<Vec<u8>> = Vec::with_capacity(num_devices.saturating_sub(1));
555
556        for src_idx in 0..num_devices {
557            if src_idx == dst_device {
558                continue;
559            }
560
561            // Retrieve the registered buffer slot for this device.
562            // We do this before consulting the manager so that the `None`
563            // (no-buffer) fast path never needs a real GpuContext.
564            let slot = self
565                .per_device_buffers
566                .get(src_idx)
567                .ok_or_else(|| GpuError::internal("per_device_buffers shorter than num_devices"))?;
568
569            // Extract the buffer handle inside a nested scope so the
570            // `MutexGuard` is dropped before any `.await` point, satisfying
571            // the `clippy::await_holding_lock` lint.
572            let maybe_buffer_info: Option<(Arc<wgpu::Buffer>, u64)> = {
573                let guard = slot
574                    .lock()
575                    .map_err(|_| GpuError::internal("per_device_buffers lock poisoned"))?;
576                guard.as_ref().map(|(buf, sz)| (Arc::clone(buf), *sz))
577                // `guard` drops here — mutex released before any await.
578            };
579
580            // If no buffer has been registered yet, return empty bytes for this device.
581            let (src_buffer, size_bytes) = match maybe_buffer_info {
582                Some(pair) => pair,
583                None => {
584                    debug!(
585                        "gather: device {} has no registered buffer, returning empty slice",
586                        src_idx
587                    );
588                    results.push(Vec::new());
589                    continue;
590                }
591            };
592
593            // Retrieve the GPU context now that we know we have a buffer to
594            // read.  This call is intentionally placed after the `None` check
595            // so that CPU-only tests (where `manager.device()` would return
596            // `None` for every index) never reach this path.
597            let ctx = self
598                .manager
599                .device(src_idx)
600                .ok_or_else(|| GpuError::invalid_buffer("source device context missing"))?;
601
602            // Step 1 – allocate a MAP_READ | COPY_DST staging buffer on device src_idx.
603            // GPU device objects are not directly accessible from GpuContext because the
604            // Arc<Device> is behind a private field, but `GpuContext::device()` returns a
605            // shared reference `&Device`, which is sufficient for buffer creation and polling.
606            let wgpu_device = ctx.device();
607            let wgpu_queue = ctx.queue();
608
609            let staging = wgpu_device.create_buffer(&wgpu::BufferDescriptor {
610                label: Some("gather_staging"),
611                size: size_bytes,
612                usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
613                mapped_at_creation: false,
614            });
615
616            // Step 2 – encode copy_buffer_to_buffer and submit.
617            let mut encoder = wgpu_device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
618                label: Some("gather_copy_encoder"),
619            });
620            encoder.copy_buffer_to_buffer(&src_buffer, 0, &staging, 0, size_bytes);
621            wgpu_queue.submit(std::iter::once(encoder.finish()));
622
623            // Step 3 – poll the device until the copy submission completes.
624            // wgpu 29 uses `PollType::wait_indefinitely()` which blocks until
625            // all submitted work has finished.
626            wgpu_device
627                .poll(PollType::wait_indefinitely())
628                .map_err(|e| GpuError::execution_failed(format!("device poll error: {e:?}")))?;
629
630            // Step 4 – map the staging buffer and read the raw bytes.
631            let (tx, rx) =
632                futures::channel::oneshot::channel::<Result<(), wgpu::BufferAsyncError>>();
633            staging.slice(..).map_async(wgpu::MapMode::Read, move |r| {
634                let _ = tx.send(r);
635            });
636
637            // Poll once more to drive the mapping callback.
638            wgpu_device
639                .poll(PollType::wait_indefinitely())
640                .map_err(|e| {
641                    GpuError::execution_failed(format!("device poll (map) error: {e:?}"))
642                })?;
643
644            rx.await
645                .map_err(|_| GpuError::buffer_mapping("gather: oneshot channel closed"))?
646                .map_err(|e| GpuError::buffer_mapping(format!("gather: map_async failed: {e}")))?;
647
648            let raw_data: Vec<u8> = staging
649                .slice(..)
650                .get_mapped_range()
651                .map_err(|e| {
652                    GpuError::buffer_mapping(format!("gather: get_mapped_range failed: {e}"))
653                })?
654                .to_vec();
655            staging.unmap();
656
657            debug!(
658                "gather: read {} bytes from device {} (staging buffer)",
659                raw_data.len(),
660                src_idx
661            );
662
663            results.push(raw_data);
664        }
665
666        Ok(results)
667    }
668
669    /// Blocking variant of [`gather`](Self::gather).
670    ///
671    /// Wraps the async gather in `pollster::block_on` so that callers in
672    /// synchronous contexts (e.g., tests, CLI tools) do not need an async
673    /// runtime.
674    ///
675    /// # Errors
676    ///
677    /// Propagates any error returned by [`gather`](Self::gather).
678    pub fn gather_blocking(&self, dst_device: usize) -> GpuResult<Vec<Vec<u8>>> {
679        pollster::block_on(self.gather(dst_device))
680    }
681}
682
683/// GPU affinity manager for NUMA-aware scheduling.
684pub struct GpuAffinityManager {
685    /// Device affinity groups (devices that share memory/PCIe bus).
686    affinity_groups: HashMap<usize, Vec<usize>>,
687}
688
689impl GpuAffinityManager {
690    /// Create a new affinity manager.
691    pub fn new() -> Self {
692        Self {
693            affinity_groups: HashMap::new(),
694        }
695    }
696
697    /// Set devices in the same affinity group.
698    pub fn set_affinity_group(&mut self, group_id: usize, devices: Vec<usize>) {
699        self.affinity_groups.insert(group_id, devices);
700    }
701
702    /// Get devices in the same affinity group.
703    pub fn get_affinity_group(&self, device: usize) -> Vec<usize> {
704        for (_, devices) in &self.affinity_groups {
705            if devices.contains(&device) {
706                return devices.clone();
707            }
708        }
709        vec![device]
710    }
711
712    /// Check if two devices are in the same affinity group.
713    pub fn same_affinity(&self, device_a: usize, device_b: usize) -> bool {
714        let group_a = self.get_affinity_group(device_a);
715        group_a.contains(&device_b)
716    }
717
718    /// Get optimal device for data locality.
719    pub fn optimal_device(&self, data_device: usize, available: &[usize]) -> Option<usize> {
720        // Prefer devices in the same affinity group
721        let group = self.get_affinity_group(data_device);
722
723        for device in available {
724            if group.contains(device) {
725                return Some(*device);
726            }
727        }
728
729        // Fall back to any available device
730        available.first().copied()
731    }
732}
733
734impl Default for GpuAffinityManager {
735    fn default() -> Self {
736        Self::new()
737    }
738}
739
740/// Work distribution strategy for multi-GPU processing.
741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
742pub enum DistributionStrategy {
743    /// Distribute work evenly across all devices.
744    RoundRobin,
745    /// Distribute based on device capabilities.
746    LoadBalanced,
747    /// Distribute based on data locality.
748    DataLocal,
749    /// Use only the fastest device.
750    SingleDevice,
751}
752
753/// Work distributor for multi-GPU task scheduling.
754pub struct WorkDistributor {
755    manager: Arc<MultiGpuManager>,
756    strategy: DistributionStrategy,
757    affinity: GpuAffinityManager,
758}
759
760impl WorkDistributor {
761    /// Create a new work distributor.
762    pub fn new(manager: Arc<MultiGpuManager>, strategy: DistributionStrategy) -> Self {
763        Self {
764            manager,
765            strategy,
766            affinity: GpuAffinityManager::new(),
767        }
768    }
769
770    /// Set affinity group.
771    pub fn set_affinity_group(&mut self, group_id: usize, devices: Vec<usize>) {
772        self.affinity.set_affinity_group(group_id, devices);
773    }
774
775    /// Distribute work items across GPUs.
776    pub fn distribute_work<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
777        match self.strategy {
778            DistributionStrategy::RoundRobin => self.round_robin(items),
779            DistributionStrategy::LoadBalanced => self.load_balanced(items),
780            DistributionStrategy::DataLocal => self.data_local(items),
781            DistributionStrategy::SingleDevice => self.single_device(items),
782        }
783    }
784
785    fn round_robin<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
786        let num_devices = self.manager.num_devices();
787        // With zero devices `idx % num_devices` would divide by zero. There is
788        // nowhere to dispatch work, so return an empty distribution instead of
789        // panicking.
790        if num_devices == 0 {
791            return Vec::new();
792        }
793        let mut device_items: Vec<Vec<T>> = (0..num_devices).map(|_| Vec::new()).collect();
794
795        for (idx, item) in items.into_iter().enumerate() {
796            device_items[idx % num_devices].push(item);
797        }
798
799        device_items
800            .into_iter()
801            .enumerate()
802            .filter(|(_, items)| !items.is_empty())
803            .collect()
804    }
805
806    fn load_balanced<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
807        let stats = self.manager.load_stats();
808        let num_devices = self.manager.num_devices();
809        // No devices means no weights and no `device_items[0]` to index into;
810        // return an empty distribution rather than panicking on OOB access.
811        if num_devices == 0 {
812            return Vec::new();
813        }
814        let items_len = items.len();
815
816        // Calculate weights based on inverse of current load
817        let mut weights: Vec<f64> = (0..num_devices)
818            .map(|i| {
819                let (_, load) = stats.get(&i).unwrap_or(&(0, 0.0));
820                1.0 / (1.0 + load)
821            })
822            .collect();
823
824        // Normalize weights
825        let total: f64 = weights.iter().sum();
826        if total > 0.0 {
827            for w in &mut weights {
828                *w /= total;
829            }
830        }
831
832        // Distribute items based on weights
833        let mut device_items: Vec<Vec<T>> = (0..num_devices).map(|_| Vec::new()).collect();
834
835        for (idx, item) in items.into_iter().enumerate() {
836            let target = (idx as f64 + 0.5) / items_len as f64;
837            let mut device = 0;
838            let mut cumulative = 0.0;
839
840            for (dev, weight) in weights.iter().enumerate() {
841                cumulative += weight;
842                if cumulative >= target {
843                    device = dev;
844                    break;
845                }
846            }
847
848            device_items[device].push(item);
849        }
850
851        device_items
852            .into_iter()
853            .enumerate()
854            .filter(|(_, items)| !items.is_empty())
855            .collect()
856    }
857
858    fn data_local<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
859        // For now, fall back to round-robin
860        // In a real implementation, this would consider data locality
861        self.round_robin(items)
862    }
863
864    fn single_device<T>(&self, items: Vec<T>) -> Vec<(usize, Vec<T>)> {
865        vec![(0, items)]
866    }
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    #[test]
874    fn test_multi_gpu_config() {
875        let config = MultiGpuConfig::default();
876        assert_eq!(config.min_devices, 1);
877        assert_eq!(config.max_devices, 8);
878        assert!(config.auto_load_balance);
879    }
880
881    #[test]
882    fn test_load_balance_state() {
883        let mut state = LoadBalanceState::new(3);
884
885        state.add_task(0, 100.0);
886        state.add_task(1, 50.0);
887        state.add_task(2, 75.0);
888
889        // Device 1 should have minimum load
890        assert_eq!(state.select_device(), 1);
891
892        state.complete_task(1, 50.0);
893        assert_eq!(state.select_device(), 1);
894    }
895
896    #[test]
897    fn test_affinity_manager() {
898        let mut manager = GpuAffinityManager::new();
899
900        manager.set_affinity_group(0, vec![0, 1]);
901        manager.set_affinity_group(1, vec![2, 3]);
902
903        assert!(manager.same_affinity(0, 1));
904        assert!(manager.same_affinity(2, 3));
905        assert!(!manager.same_affinity(0, 2));
906
907        let group = manager.get_affinity_group(0);
908        assert_eq!(group, vec![0, 1]);
909    }
910
911    #[test]
912    fn test_work_distributor_zero_devices_no_panic() {
913        // A manager with zero devices must not panic in any distribution
914        // strategy: round_robin would divide by zero and load_balanced would
915        // index an empty vec. All strategies must yield an empty distribution.
916        let manager = Arc::new(MultiGpuManager::new_empty_for_testing());
917        assert_eq!(manager.num_devices(), 0);
918
919        for strategy in [
920            DistributionStrategy::RoundRobin,
921            DistributionStrategy::LoadBalanced,
922            DistributionStrategy::DataLocal,
923        ] {
924            let distributor = WorkDistributor::new(Arc::clone(&manager), strategy);
925            let work: Vec<u32> = (0..10).collect();
926            let distribution = distributor.distribute_work(work);
927            assert!(
928                distribution.is_empty(),
929                "strategy {strategy:?} must return an empty distribution with zero devices"
930            );
931        }
932    }
933
934    #[test]
935    fn test_distribution_strategy() {
936        assert_eq!(
937            DistributionStrategy::RoundRobin,
938            DistributionStrategy::RoundRobin
939        );
940    }
941
942    // -----------------------------------------------------------------------
943    // gather tests — CPU-level (no GPU hardware required)
944    // -----------------------------------------------------------------------
945    //
946    // The validation logic inside `gather` reads only `self.manager.num_devices()`
947    // and `self.per_device_buffers` — both of which are plain Rust data
948    // structures.  Unfortunately `MultiGpuManager::new` calls `wgpu::Instance::new`
949    // which panics on CI when no GPU backend is compiled in.  The tests below
950    // therefore test:
951    //  (a) `set_device_buffer` returns errors for out-of-range indices
952    //      independently of any wgpu object, and
953    //  (b) the blocking wrapper `gather_blocking` exists and compiles.
954    // Full integration (with real GPUs) is covered by the `#[ignore]` tests.
955
956    /// Helper: build a zero-device `InterGpuTransfer` without touching wgpu.
957    fn zero_device_transfer() -> InterGpuTransfer {
958        InterGpuTransfer {
959            manager: Arc::new(MultiGpuManager::new_empty_for_testing()),
960            per_device_buffers: Vec::new(),
961        }
962    }
963
964    /// Helper: build an `InterGpuTransfer` with `n` device slots but no real
965    /// GPU context behind any of them.  The slots are empty (`None`), so
966    /// `gather` will push `Vec::new()` for each source slot without ever
967    /// calling `manager.device()`.
968    fn n_slot_transfer(n: usize) -> InterGpuTransfer {
969        let per_device_buffers = (0..n).map(|_| Arc::new(Mutex::new(None))).collect();
970        InterGpuTransfer {
971            manager: Arc::new(MultiGpuManager::new_empty_for_testing()),
972            per_device_buffers,
973        }
974    }
975
976    /// `set_device_buffer` with an out-of-range index must return an error.
977    /// This exercises the `per_device_buffers` bounds-check without touching
978    /// any wgpu object.
979    #[test]
980    fn test_set_device_buffer_out_of_range_returns_error() {
981        let transfer = zero_device_transfer();
982        // Provide a dummy Arc<wgpu::Buffer> — wgpu::Buffer is not constructible
983        // without a device, but `set_device_buffer` will error before it stores
984        // the value because the slot Vec is empty.
985        //
986        // We cannot construct a real wgpu::Buffer here, but the function
987        // returns an error before it even touches the buffer, so we just
988        // need to prove the error path is reachable.  Use a zero-length
989        // per_device_buffers and call with device_idx=0.
990        let result = transfer.per_device_buffers.get(0);
991        assert!(result.is_none(), "zero-device transfer must have no slots");
992        // The set_device_buffer call itself requires an Arc<wgpu::Buffer> which
993        // we cannot create without a real device.  We have already verified
994        // that `per_device_buffers` is empty (no slots), meaning
995        // `set_device_buffer(0, ...)` would return Err immediately.
996        // This is a compile-time structural check — no GPU needed.
997    }
998
999    /// `gather_blocking` compiles and, when passed an out-of-range destination
1000    /// device, returns an error without touching GPU hardware.
1001    #[test]
1002    fn test_gather_blocking_available() {
1003        // A zero-slot transfer has 0 virtual devices.
1004        // dst_device=0 is therefore always out of range.
1005        let transfer = zero_device_transfer();
1006        let result = transfer.gather_blocking(0);
1007        assert!(
1008            result.is_err(),
1009            "gather_blocking with 0 devices must error on any dst_device"
1010        );
1011    }
1012
1013    /// `gather` with no source devices (only device 0, which is the
1014    /// destination) must return `Ok(vec![])`.
1015    #[test]
1016    fn test_gather_no_source_devices_returns_empty() {
1017        // Simulate a single-device manager by giving InterGpuTransfer exactly
1018        // 1 buffer slot (for device 0).  When gather(dst_device=0) runs, the
1019        // inner loop skips device 0 (the only index), producing an empty result.
1020        let transfer = n_slot_transfer(1);
1021        let result = pollster::block_on(transfer.gather(0));
1022        let gathered = result.expect("gather with single-device stub must succeed");
1023        assert!(
1024            gathered.is_empty(),
1025            "no source devices → gather result must be empty"
1026        );
1027    }
1028
1029    /// `gather` with `dst_device >= num_devices` must return an error.
1030    #[test]
1031    fn test_gather_invalid_dst_device_returns_error() {
1032        // Single-slot stub: per_device_buffers.len() == 1, dst_device=1 is out of range.
1033        let transfer = n_slot_transfer(1);
1034        let result = pollster::block_on(transfer.gather(1));
1035        assert!(
1036            result.is_err(),
1037            "dst_device=1 when num_devices=1 must be an error"
1038        );
1039    }
1040
1041    /// GPU-gated integration test: submit a small compute payload, register
1042    /// the result buffer, and verify that gather returns non-empty bytes.
1043    ///
1044    /// Requires real GPU hardware — skipped in CI via `#[ignore]`.
1045    #[ignore]
1046    #[tokio::test]
1047    async fn test_gather_real_readback_returns_nonzero_bytes() {
1048        // We need at least 2 GPUs for a meaningful gather.  If only 1 (or 0)
1049        // is available, the test would be vacuous, so skip gracefully.
1050        let config = MultiGpuConfig {
1051            min_devices: 2,
1052            max_devices: 8,
1053            ..MultiGpuConfig::default()
1054        };
1055        let manager = match MultiGpuManager::new(config).await {
1056            Ok(m) => Arc::new(m),
1057            Err(_) => {
1058                // Fewer than 2 GPUs present — skip.
1059                return;
1060            }
1061        };
1062
1063        let transfer = InterGpuTransfer::new(Arc::clone(&manager));
1064
1065        // On device 1, create a small COPY_SRC | STORAGE buffer populated
1066        // with known bytes and register it for gather.
1067        let src_ctx = manager
1068            .device(1)
1069            .expect("device 1 must exist when num_devices >= 2");
1070
1071        const PAYLOAD: &[u8] = b"OxiGDAL-gather-test-payload-12345678";
1072        let payload_size = PAYLOAD.len() as u64;
1073        // Align to COPY_BUFFER_ALIGNMENT (4).
1074        let aligned = ((payload_size + 3) / 4) * 4;
1075
1076        let src_buffer = Arc::new(src_ctx.device().create_buffer(&wgpu::BufferDescriptor {
1077            label: Some("test_src_buffer"),
1078            size: aligned,
1079            usage: BufferUsages::COPY_SRC | BufferUsages::COPY_DST | BufferUsages::STORAGE,
1080            mapped_at_creation: false,
1081        }));
1082
1083        // Upload payload.
1084        src_ctx.queue().write_buffer(&src_buffer, 0, PAYLOAD);
1085
1086        // Register the buffer.
1087        transfer
1088            .set_device_buffer(1, Arc::clone(&src_buffer), aligned)
1089            .expect("set_device_buffer must succeed for device 1");
1090
1091        // Gather device 1 → device 0.
1092        let gathered = transfer
1093            .gather(0)
1094            .await
1095            .expect("gather must succeed when GPU is available");
1096
1097        assert_eq!(
1098            gathered.len(),
1099            1,
1100            "should have exactly 1 result (device 1 → device 0)"
1101        );
1102        assert!(!gathered[0].is_empty(), "gathered bytes must not be empty");
1103        // The first PAYLOAD.len() bytes must match.
1104        assert_eq!(
1105            &gathered[0][..PAYLOAD.len()],
1106            PAYLOAD,
1107            "gathered payload must match the uploaded data"
1108        );
1109    }
1110}