vyre_driver_wgpu/engine/streaming/
async_copy.rs1use std::sync::{mpsc, LazyLock};
16
17use crossbeam_channel::{bounded, Receiver as CrossbeamReceiver, Sender as CrossbeamSender};
18use rustc_hash::FxHashMap;
19use vyre_driver::BackendError;
20
21use crate::thread_pool::{BoundedWorkerJob, BoundedWorkerPool};
22
23enum TokioBlockingCompletion {
25 Returned(Result<(), BackendError>),
26 Panicked(String),
27}
28
29enum InFlight {
34 Pool {
35 completion: CrossbeamReceiver<Result<(), BackendError>>,
36 },
37 TokioBlocking {
38 completion: mpsc::Receiver<TokioBlockingCompletion>,
39 task: tokio::task::JoinHandle<()>,
40 },
41}
42
43struct AsyncCopyJob {
44 copy: Box<dyn FnOnce() -> Result<(), BackendError> + Send + 'static>,
45 response: CrossbeamSender<Result<(), BackendError>>,
46}
47
48impl BoundedWorkerJob for AsyncCopyJob {
49 type Output = ();
50
51 fn response(&self) -> &CrossbeamSender<Result<Self::Output, BackendError>> {
52 &self.response
53 }
54
55 fn run(self) -> Result<Self::Output, BackendError> {
56 (self.copy)()
57 }
58}
59
60struct AsyncCopyPool {
61 pool: BoundedWorkerPool<AsyncCopyJob>,
62}
63
64impl AsyncCopyPool {
65 fn global() -> Result<&'static Self, BackendError> {
66 static POOL: LazyLock<Result<AsyncCopyPool, BackendError>> =
67 LazyLock::new(AsyncCopyPool::new);
68 POOL.as_ref()
69 .map_err(|error| BackendError::new(error.to_string()))
70 }
71
72 fn new() -> Result<Self, BackendError> {
73 Ok(Self {
74 pool: BoundedWorkerPool::new(
75 256,
76 "vyre-wgpu-async-copy",
77 "inspect async copy staging buffer ownership and copy closure invariants.",
78 "reduce process thread count or increase system nproc limit.",
79 )?,
80 })
81 }
82
83 fn submit<F>(
84 &self,
85 copy: F,
86 ) -> Result<CrossbeamReceiver<Result<(), BackendError>>, BackendError>
87 where
88 F: FnOnce() -> Result<(), BackendError> + Send + 'static,
89 {
90 let (response, completion) = bounded(1);
91 self.pool.submit_blocking(
92 AsyncCopyJob {
93 copy: Box::new(copy),
94 response,
95 },
96 "recreate the process; the async-copy worker pool only closes during shutdown.",
97 )?;
98 Ok(completion)
99 }
100}
101
102#[derive(Default)]
104pub struct AsyncCopyStreams {
105 in_flight: FxHashMap<String, InFlight>,
106}
107
108impl AsyncCopyStreams {
109 #[must_use]
111 pub fn new() -> Self {
112 Self::default()
113 }
114
115 pub fn async_load<F>(&mut self, tag: impl Into<String>, copy: F) -> Result<(), BackendError>
126 where
127 F: FnOnce() -> Result<(), BackendError> + Send + 'static,
128 {
129 let tag = tag.into();
130 if self.in_flight.contains_key(&tag) {
131 return Err(BackendError::new(format!(
132 "async copy tag `{tag}` is already in flight. Fix: wait before reusing a stream tag."
133 )));
134 }
135 let handle = match tokio::runtime::Handle::try_current() {
136 Ok(rt) => {
137 let (completion_tx, completion_rx) = mpsc::sync_channel(1);
138 let task = rt.spawn_blocking(move || {
139 let completion =
140 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(copy)) {
141 Ok(result) => TokioBlockingCompletion::Returned(result),
142 Err(payload) => {
143 TokioBlockingCompletion::Panicked(panic_payload(&*payload).into())
144 }
145 };
146 if completion_tx.send(completion).is_err() {
147 tracing::warn!(
148 "Fix: async-copy completion receiver was dropped before the blocking copy reported completion."
149 );
150 }
151 });
152 InFlight::TokioBlocking {
153 completion: completion_rx,
154 task,
155 }
156 }
157 Err(_) => InFlight::Pool {
158 completion: AsyncCopyPool::global()?.submit(copy)?,
159 },
160 };
161 self.in_flight.insert(tag, handle);
162 Ok(())
163 }
164
165 pub fn async_wait(&mut self, tag: &str) -> Result<(), BackendError> {
172 let handle = self.in_flight.remove(tag).ok_or_else(|| {
173 BackendError::new(format!(
174 "async copy tag `{tag}` has no matching AsyncLoad. Fix: emit AsyncLoad before AsyncWait."
175 ))
176 })?;
177 match handle {
178 InFlight::Pool { completion } => completion.recv().map_err(|error| {
179 BackendError::new(format!(
180 "async copy worker for `{tag}` exited without publishing completion: {error}. Fix: inspect staging buffer ownership and copy closure invariants."
181 ))
182 })?,
183 InFlight::TokioBlocking { completion, task } => {
184 let completion = completion.recv().map_err(|_| {
185 BackendError::new(format!(
186 "async copy worker for `{tag}` exited without publishing completion. Fix: inspect staging buffer ownership and copy closure invariants."
187 ))
188 })?;
189 drop(task);
190 match completion {
191 TokioBlockingCompletion::Returned(result) => result,
192 TokioBlockingCompletion::Panicked(payload) => Err(BackendError::new(
193 format!(
194 "async copy worker for `{tag}` panicked: {payload}. Fix: inspect staging buffer ownership and copy closure invariants."
195 ),
196 )),
197 }
198 }
199 }
200 }
201
202 pub fn overlap_copy_compute<C, G>(
208 &mut self,
209 tag: impl Into<String>,
210 copy: C,
211 compute: G,
212 ) -> Result<(), BackendError>
213 where
214 C: FnOnce() -> Result<(), BackendError> + Send + 'static,
215 G: FnOnce() -> Result<(), BackendError>,
216 {
217 let tag = tag.into();
218 self.async_load(tag.clone(), copy)?;
219 compute()?;
220 self.async_wait(&tag)
221 }
222}
223
224impl Drop for AsyncCopyStreams {
225 fn drop(&mut self) {
226 for (_, handle) in self.in_flight.drain() {
227 match handle {
228 InFlight::Pool { .. } => {}
229 InFlight::TokioBlocking { task, .. } => {
230 task.abort();
235 }
236 }
237 }
238 }
239}
240
241fn panic_payload<'a>(payload: &'a (dyn std::any::Any + Send + 'static)) -> &'a str {
242 payload
243 .downcast_ref::<&'static str>()
244 .copied()
245 .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
246 .unwrap_or("<non-string panic payload>")
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use std::time::Duration;
253
254 #[test]
255 fn async_copy_compute_overlap_is_synchronized_without_sleep() {
256 let (copy_started_tx, copy_started_rx) = mpsc::sync_channel(1);
257 let (release_copy_tx, release_copy_rx) = mpsc::sync_channel(1);
258 let mut streams = AsyncCopyStreams::new();
259 streams
260 .overlap_copy_compute(
261 "stage-0",
262 move || {
263 copy_started_tx
264 .send(())
265 .expect("Fix: compute side must stay alive until copy starts");
266 release_copy_rx
267 .recv()
268 .expect("Fix: compute side must release copy before wait");
269 Ok(())
270 },
271 move || {
272 copy_started_rx
273 .recv_timeout(Duration::from_secs(5))
274 .expect("Fix: copy work must start before compute can release it");
275 release_copy_tx
276 .send(())
277 .expect("Fix: copy side must stay alive until compute releases it");
278 Ok(())
279 },
280 )
281 .expect("Fix: async copy and compute should complete");
282 }
283
284 #[test]
285 fn tokio_blocking_wait_does_not_need_live_runtime() {
286 let runtime = tokio::runtime::Builder::new_multi_thread()
287 .worker_threads(1)
288 .enable_all()
289 .build()
290 .expect("Fix: tokio runtime must build for async copy test");
291 let mut streams = AsyncCopyStreams::new();
292 {
293 let _guard = runtime.enter();
294 streams
295 .async_load("stage-0", || Ok(()))
296 .expect("Fix: async load must enqueue on active tokio runtime");
297 }
298 drop(runtime);
299
300 streams
301 .async_wait("stage-0")
302 .expect("Fix: async wait must join through completion channel without a live runtime");
303 }
304}