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}