Skip to main content

scirs2_core/gpu/
async_transfer.rs

1//! Asynchronous GPU buffer transfer pipeline.
2//!
3//! Enables overlapping CPU computation with GPU data transfers by providing
4//! a pipeline abstraction that accepts transfer requests, tracks their
5//! completion through atomic flags, and simulates immediate completion in
6//! CPU-fallback mode.
7
8use std::collections::VecDeque;
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11use thiserror::Error;
12
13/// Transfer direction for async GPU operations.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TransferDirection {
16    /// Copy data from host (CPU) memory to device (GPU) memory.
17    HostToDevice,
18    /// Copy data from device (GPU) memory to host (CPU) memory.
19    DeviceToHost,
20    /// Copy data between two device (GPU) memory regions.
21    DeviceToDevice,
22}
23
24/// A pending async transfer operation stored internally in the pipeline.
25struct AsyncTransfer<T> {
26    #[allow(dead_code)]
27    data: Vec<T>,
28    #[allow(dead_code)]
29    direction: TransferDirection,
30    handle: TransferHandle,
31}
32
33/// Error type for async transfer pipeline operations.
34#[derive(Debug, Error)]
35pub enum AsyncTransferError {
36    /// Returned when a submit is attempted and the pipeline already holds
37    /// `max_pending` unfinished transfers.
38    #[error("Pipeline full: {0} pending transfers")]
39    PipelineFull(usize),
40
41    /// Returned when a transfer ID is queried but no matching handle exists.
42    #[error("Transfer ID {0} not found")]
43    NotFound(u64),
44
45    /// Returned when the internal lock cannot be acquired.
46    #[error("Failed to acquire pipeline lock")]
47    LockError,
48}
49
50/// An opaque handle returned by [`AsyncTransferPipeline::submit`].
51///
52/// Use [`TransferHandle::is_complete`] to poll completion without blocking,
53/// or pass the handle to [`AsyncTransferPipeline::flush`] to drain all pending
54/// work.
55#[derive(Debug, Clone)]
56pub struct TransferHandle {
57    /// Unique monotonically-increasing identifier for this transfer.
58    pub id: u64,
59    completed: Arc<AtomicBool>,
60}
61
62impl TransferHandle {
63    /// Returns `true` when the underlying transfer has finished.
64    ///
65    /// In CPU simulation mode this is always `true` immediately after
66    /// submission.
67    pub fn is_complete(&self) -> bool {
68        self.completed.load(Ordering::Acquire)
69    }
70}
71
72/// Pipeline that manages a bounded queue of in-flight async transfers.
73///
74/// In CPU simulation mode every submitted transfer completes synchronously
75/// (the completion flag is set to `true` before `submit` returns).  This
76/// means the pipeline compiles and passes tests on machines without real GPU
77/// hardware.
78///
79/// # Type parameters
80///
81/// * `T` – The element type of the transfer buffers.  Must be `Clone + Send +
82///   'static`.
83pub struct AsyncTransferPipeline<T> {
84    pending: Mutex<VecDeque<AsyncTransfer<T>>>,
85    max_pending: usize,
86    id_counter: AtomicU64,
87}
88
89impl<T: Clone + Send + 'static> AsyncTransferPipeline<T> {
90    /// Create a new pipeline that allows up to `max_pending` in-flight
91    /// transfers before returning [`AsyncTransferError::PipelineFull`].
92    ///
93    /// A `max_pending` of 0 is valid and will cause every `submit` to fail
94    /// immediately.
95    pub fn new(max_pending: usize) -> Self {
96        Self {
97            pending: Mutex::new(VecDeque::new()),
98            max_pending,
99            id_counter: AtomicU64::new(1),
100        }
101    }
102
103    /// Submit a transfer request for `data` in the given `direction`.
104    ///
105    /// In CPU simulation mode the transfer completes immediately: the returned
106    /// [`TransferHandle`] will already report `is_complete() == true`.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`AsyncTransferError::PipelineFull`] when the number of
111    /// currently pending (incomplete) transfers equals `max_pending`.
112    pub fn submit(
113        &self,
114        data: Vec<T>,
115        direction: TransferDirection,
116    ) -> Result<TransferHandle, AsyncTransferError> {
117        let mut queue = self
118            .pending
119            .lock()
120            .map_err(|_| AsyncTransferError::LockError)?;
121
122        // Count only incomplete transfers against the cap.
123        let in_flight = queue.iter().filter(|t| !t.handle.is_complete()).count();
124        if in_flight >= self.max_pending {
125            return Err(AsyncTransferError::PipelineFull(in_flight));
126        }
127
128        let id = self.id_counter.fetch_add(1, Ordering::Relaxed);
129        let completed = Arc::new(AtomicBool::new(false));
130        let handle = TransferHandle {
131            id,
132            completed: Arc::clone(&completed),
133        };
134
135        // CPU simulation: mark the transfer as immediately complete.
136        completed.store(true, Ordering::Release);
137
138        queue.push_back(AsyncTransfer {
139            data,
140            direction,
141            handle: handle.clone(),
142        });
143
144        Ok(handle)
145    }
146
147    /// Returns `true` if the transfer identified by `handle` has completed.
148    ///
149    /// This is a convenience wrapper around [`TransferHandle::is_complete`].
150    pub fn is_complete(&self, handle: &TransferHandle) -> bool {
151        handle.is_complete()
152    }
153
154    /// Block until all pending transfers have completed, then drain the queue.
155    ///
156    /// In CPU simulation mode this returns immediately because all transfers
157    /// are marked complete on submission.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`AsyncTransferError::LockError`] if the internal mutex cannot
162    /// be acquired.
163    pub fn flush(&self) -> Result<(), AsyncTransferError> {
164        let mut queue = self
165            .pending
166            .lock()
167            .map_err(|_| AsyncTransferError::LockError)?;
168
169        // In CPU mode every transfer is already done; just drain the queue.
170        // In a real GPU implementation this would call a device-synchronise API
171        // and wait for each in-flight DMA operation.
172        queue.retain(|transfer| !transfer.handle.is_complete());
173
174        Ok(())
175    }
176
177    /// Return the number of transfers currently tracked in the pipeline
178    /// (including already-completed ones that have not yet been flushed).
179    pub fn pending_count(&self) -> usize {
180        self.pending.lock().map(|q| q.len()).unwrap_or(0)
181    }
182
183    /// Return the number of transfers that have NOT yet completed.
184    pub fn in_flight_count(&self) -> usize {
185        self.pending
186            .lock()
187            .map(|q| q.iter().filter(|t| !t.handle.is_complete()).count())
188            .unwrap_or(0)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    /// Submitting a transfer should produce a handle that is immediately
197    /// complete in CPU simulation mode.
198    #[test]
199    fn test_async_transfer_submit() {
200        let pipeline: AsyncTransferPipeline<f32> = AsyncTransferPipeline::new(8);
201
202        let data = vec![1.0_f32, 2.0, 3.0, 4.0];
203        let handle = pipeline
204            .submit(data.clone(), TransferDirection::HostToDevice)
205            .expect("submit should succeed");
206
207        assert!(
208            handle.is_complete(),
209            "handle should be complete immediately in CPU mode"
210        );
211        assert!(
212            pipeline.is_complete(&handle),
213            "pipeline.is_complete should match handle"
214        );
215    }
216
217    /// Submitting multiple transfers then flushing should leave an empty
218    /// in-flight queue.
219    #[test]
220    fn test_async_transfer_pipeline_flush() {
221        let pipeline: AsyncTransferPipeline<u8> = AsyncTransferPipeline::new(16);
222
223        for i in 0..8_u8 {
224            let data = vec![i; 64];
225            pipeline
226                .submit(data, TransferDirection::DeviceToHost)
227                .expect("submit should succeed");
228        }
229
230        // In CPU mode all are already done, but pending_count still tracks them.
231        assert_eq!(pipeline.pending_count(), 8);
232
233        pipeline.flush().expect("flush should succeed");
234
235        // After flush, completed transfers should have been drained.
236        assert_eq!(pipeline.pending_count(), 0);
237    }
238
239    /// Attempting to submit more than `max_pending` incomplete transfers should
240    /// return `PipelineFull`.
241    ///
242    /// Because CPU mode completes transfers synchronously this test uses a
243    /// max_pending of 0 to reliably trigger the error.
244    #[test]
245    fn test_async_transfer_pipeline_full() {
246        // A pipeline that never allows any in-flight transfers.
247        let pipeline: AsyncTransferPipeline<f32> = AsyncTransferPipeline::new(0);
248
249        let result = pipeline.submit(vec![0.0_f32; 4], TransferDirection::HostToDevice);
250
251        match result {
252            Err(AsyncTransferError::PipelineFull(count)) => {
253                assert_eq!(count, 0, "should report 0 in-flight when cap is 0");
254            }
255            other => panic!("expected PipelineFull, got {:?}", other),
256        }
257    }
258
259    /// DeviceToDevice transfers work the same as host-device transfers in
260    /// CPU simulation mode.
261    #[test]
262    fn test_async_transfer_device_to_device() {
263        let pipeline: AsyncTransferPipeline<i32> = AsyncTransferPipeline::new(4);
264
265        let handle = pipeline
266            .submit(vec![42_i32; 32], TransferDirection::DeviceToDevice)
267            .expect("submit should succeed");
268
269        assert!(handle.is_complete());
270    }
271
272    /// Multiple handles should each be independently complete.
273    #[test]
274    fn test_async_transfer_multiple_handles() {
275        let pipeline: AsyncTransferPipeline<f64> = AsyncTransferPipeline::new(16);
276        let mut handles = Vec::new();
277
278        for _ in 0..5 {
279            let h = pipeline
280                .submit(vec![1.0_f64; 8], TransferDirection::HostToDevice)
281                .expect("submit should succeed");
282            handles.push(h);
283        }
284
285        for (i, h) in handles.iter().enumerate() {
286            assert!(h.is_complete(), "handle {} should be complete", i);
287        }
288
289        assert_eq!(pipeline.in_flight_count(), 0);
290    }
291}