Skip to main content

vyre_driver_wgpu/engine/streaming/
async_copy.rs

1//! Host-side async copy stream scheduling.
2//!
3//! wgpu command submission already lets copies and compute live in one GPU
4//! queue. This module models the higher-level stream contract exposed by
5//! `Node::AsyncLoad { tag }` and `Node::AsyncWait { tag }`: copy staging work
6//! is started on a separate host worker and joined only when the matching wait
7//! is reached, so CPU memcpy/staging can overlap compute preparation.
8//!
9//! Backing worker policy: uses `tokio::task::spawn_blocking` when a tokio
10//! runtime is active, otherwise uses the crate-global bounded worker pool.
11//! The non-tokio path must not spawn one OS thread per AsyncLoad tag: that
12//! pattern thrashes the scheduler under heavy streaming workloads. The bounded
13//! pool caps worker count and queue depth while preserving overlap semantics.
14
15use 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
23/// Completion reported by a tokio blocking worker.
24enum TokioBlockingCompletion {
25    Returned(Result<(), BackendError>),
26    Panicked(String),
27}
28
29/// Handle to the work backing an in-flight tag. Stored in the scheduler until
30/// the matching `async_wait` call. The tokio variant carries a plain blocking
31/// receiver so `async_wait` never has to construct an emergency runtime just to
32/// join a task after the caller's runtime moved or shut down.
33enum 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/// Async copy scheduler keyed by IR stream tags.
103#[derive(Default)]
104pub struct AsyncCopyStreams {
105    in_flight: FxHashMap<String, InFlight>,
106}
107
108impl AsyncCopyStreams {
109    /// Create an empty stream scheduler.
110    #[must_use]
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Start copy work associated with `tag`.
116    ///
117    /// If a tokio runtime handle is available on the current thread the
118    /// closure is dispatched to `tokio::task::spawn_blocking` so the
119    /// runtime's blocking-thread pool amortizes scheduling cost. Otherwise the
120    /// crate-global bounded async-copy pool is used.
121    ///
122    /// # Errors
123    ///
124    /// Returns a backend error if the tag is already in flight.
125    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    /// Wait for a copy previously started by [`Self::async_load`].
166    ///
167    /// # Errors
168    ///
169    /// Returns a backend error if the tag is unknown, the worker panicked, or
170    /// the copy closure returned an error.
171    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    /// Start copy work, run compute work, then wait for the copy tag.
203    ///
204    /// # Errors
205    ///
206    /// Propagates copy or compute failures with their original context.
207    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                    // Abort if the blocking task has not started. If it is
231                    // already running, tokio lets it finish; the completion
232                    // channel is dropped so the task cannot retain scheduler
233                    // state after completion.
234                    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}