Skip to main content

query_runtime/runtime/gpu_execution_pool/
mod.rs

1//! Dedicated asynchronous executors for synchronous GPU work.
2//!
3//! Because many GPU runtime invocations block the calling CPU thread, executing
4//! them directly on a general serving runtime would pin worker threads for the
5//! duration of the device call. `GpuExecutionPool` isolates blocking GPU operations
6//! onto a per-device Tokio runtime with independently configured worker concurrency.
7//!
8//! Work reaches the device through two execution paths under shared admission limits.
9//! A job-class permit is acquired upon submission, bounding the count of accepted
10//! jobs; a saturated class returns `ServiceOverloaded` immediately, except for the
11//! admission envelope of a placed attempt, which serves as a bounded waiter.
12//! Capacities derive from the device profile (`A_d` active attempts, `S_d` auxiliary
13//! slots per attempt); the design contract is documented in
14//! `website/docs/design/admission.mdx#device-profiles-and-grants`.
15//!
16//! An async job (`run*`, `spawn*`) wraps synchronous GPU execution within an asynchronous task.
17//! The runtime functions as a fixed-width worker pool; tasks await one of `worker_count`
18//! worker permits and hold it until execution completes. Consequently, at most `worker_count`
19//! async job bodies execute simultaneously, with waiting tasks bounded by class capacities.
20//! Blocking operations (`submit_blocking*`) dispatch to dedicated blocking threads without
21//! consuming worker permits, bounded solely by class capacity. Chunk lanes and source reads
22//! utilize this path to prevent scan decodes from occupying async workers.
23//!
24//! Shutdown halts new submissions, instructs the supervisor to drop the runtime,
25//! waits for active tasks to complete, and joins the supervisor thread under
26//! the caller deadline. The process-exit handler mirrors this sequence without a deadline
27//! to guarantee no CUDA-dependent task outlives `libcudart`.
28
29use std::{
30    cell::Cell,
31    future::Future,
32    panic::AssertUnwindSafe,
33    sync::{
34        Arc, Mutex,
35        atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering},
36        mpsc,
37    },
38    time::Instant,
39};
40#[cfg(any(test, feature = "test-hooks"))]
41use std::{
42    collections::HashMap,
43    sync::{OnceLock, Weak},
44};
45
46use futures::FutureExt;
47use query_planning::error::{QueryError, QueryErrorCode, Result};
48use serde::Serialize;
49#[cfg(any(test, feature = "test-hooks"))]
50use tokio::runtime::EnterGuard;
51use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
52
53use super::{ExecutionCancellation, context::QueryServiceResourceEpoch};
54use crate::observability::{GpuJobLifecycleState, RuntimeJobId, RuntimeObservationHandle, RuntimeStreamLeaseScope};
55
56mod inner;
57mod job;
58mod lease;
59mod supervisor;
60
61use inner::{GpuExecutionPoolInner, PoolDrain, pool_closed_error};
62use job::{
63    AbortOnDropJoinHandle, JOB_LIFECYCLE_PENDING, JobCounterGuard, JobObservation, JobTerminalGuard, PoolCounters,
64    QueryCounters, current_pool_id, current_worker_index, decrement, increment_with_peak, job_cancelled_error,
65    job_join_panic_error, job_panic_error, run_counted_job, run_counted_job_scoped, validate_worker_count,
66};
67use lease::{BlockingCompletionNotify, BlockingWorkOutcome};
68pub use lease::{GpuExecutorLease, PoolBlockingWork};
69use supervisor::RuntimeSupervisor;
70
71const BUILD_OPERATION: &str = "query_engine.gpu_execution_pool.build";
72const RUN_OPERATION: &str = "query_engine.gpu_execution_pool.run";
73#[cfg(any(test, feature = "test-hooks"))]
74const SHARED_OPERATION: &str = "query_engine.gpu_execution_pool.shared";
75const SHUTDOWN_OPERATION: &str = "query_engine.gpu_execution_pool.shutdown";
76
77static NEXT_POOL_ID: AtomicU64 = AtomicU64::new(1);
78
79thread_local! {
80    static CURRENT_POOL_ID: Cell<Option<u64>> = const { Cell::new(None) };
81    static CURRENT_WORKER_INDEX: Cell<Option<usize>> = const { Cell::new(None) };
82}
83
84/// Classifies work submitted to the backend-owned GPU execution pool.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum GpuExecutionJobKind {
87    /// CUDA and execution-context initialization.
88    ContextSetup,
89    /// One admitted attempt's root query execution job.
90    QueryExecution,
91    /// Auxiliary work an admitted attempt issues underneath its root job:
92    /// execution lanes and scheduled source reads. Every such submission is
93    /// backed by the caller's attempt auxiliary permit.
94    AttemptWork,
95    /// Resource synchronization and destruction detached from request serving.
96    Cleanup,
97}
98
99/// Workers each pool keeps for lifecycle jobs (context setup and release
100/// cleanup). Query execution and its auxiliary work run only on the other
101/// workers, so a placed admission's envelope and a finished attempt's cleanup
102/// never wait behind running queries.
103pub(crate) const RESERVED_LIFECYCLE_WORKERS: usize = 1;
104
105/// Resource class partitioning for bounded worker admission.
106///
107/// For active-attempt limit `A_d` and per-attempt source-work bound `S_d`,
108/// each device reserves `A_d` lifecycle job slots and `A_d * S_d` execution
109/// job slots. Additional fragment setup is execution work. Child work can
110/// never consume a future lifecycle slot: class permits are acquired before a
111/// job occupies a worker thread, and no hidden fallback pool exists.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub(crate) enum GpuExecutionJobClass {
114    /// Per-attempt lifecycle work: initial context setup and cleanup.
115    Lifecycle,
116    /// Bounded query execution and additional fragment setup.
117    Execution,
118}
119
120impl GpuExecutionJobClass {
121    /// Stable diagnostic label for this job class (`root` or `auxiliary`).
122    pub const fn as_str(self) -> &'static str {
123        match self {
124            Self::Lifecycle => "root",
125            Self::Execution => "auxiliary",
126        }
127    }
128}
129
130impl GpuExecutionJobKind {
131    /// Stable diagnostic label for this job kind.
132    pub const fn as_str(self) -> &'static str {
133        match self {
134            Self::ContextSetup => "context_setup",
135            Self::QueryExecution => "query_execution",
136            Self::AttemptWork => "attempt_work",
137            Self::Cleanup => "cleanup",
138        }
139    }
140
141    /// Resource class admitted for this job kind.
142    ///
143    /// A root query job is per-attempt lifecycle work, like the context setup
144    /// it follows; only the auxiliary work that job issues underneath itself
145    /// is execution-class. Context setup is the one kind whose class is taken
146    /// from the attempt's own claim, so it is selected explicitly through
147    /// [`GpuExecutionPool::run_observed_scoped_in_class`].
148    pub(crate) const fn job_class(self) -> GpuExecutionJobClass {
149        match self {
150            Self::ContextSetup | Self::QueryExecution | Self::Cleanup => GpuExecutionJobClass::Lifecycle,
151            Self::AttemptWork => GpuExecutionJobClass::Execution,
152        }
153    }
154
155    /// Whether the job runs only on workers outside
156    /// [`RESERVED_LIFECYCLE_WORKERS`].
157    pub(crate) const fn runs_outside_lifecycle_reserve(self) -> bool {
158        matches!(self, Self::QueryExecution | Self::AttemptWork)
159    }
160}
161
162/// Point-in-time counters for one device's GPU execution pool.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
164pub struct GpuExecutionPoolSnapshot {
165    /// Opaque process-local identity of the backend-owned pool instance.
166    ///
167    /// Clones of the same pool report the same value. A newly constructed pool
168    /// reports a distinct value, including after a backend generation changes.
169    pub pool_id: u64,
170    /// CUDA device ordinal associated with the pool.
171    pub device_ordinal: usize,
172    /// Maximum number of concurrently active jobs.
173    pub worker_count: usize,
174    /// Jobs that have started and not yet completed.
175    pub active_jobs: usize,
176    /// Highest observed value of [`Self::active_jobs`].
177    pub(crate) peak_active_jobs: usize,
178    /// Accepted jobs that have not started.
179    pub queued_jobs: usize,
180    /// Highest observed value of [`Self::queued_jobs`].
181    pub(crate) peak_queued_jobs: usize,
182    /// Active query-execution jobs on this device.
183    pub active_query_jobs: usize,
184    /// Highest observed device-local query-execution concurrency.
185    pub peak_active_query_jobs: usize,
186    /// Configured lifecycle-class job slots (`root` diagnostic label).
187    pub(crate) root_job_capacity: usize,
188    /// Configured execution-class job slots (`auxiliary` diagnostic label).
189    pub(crate) aux_job_capacity: usize,
190    /// Lifecycle-class job slots currently free (`root` diagnostic label).
191    pub(crate) root_jobs_available: usize,
192    /// Execution-class job slots currently free (`auxiliary` diagnostic label).
193    pub(crate) aux_jobs_available: usize,
194}
195
196/// Point-in-time counters shared by a group of GPU execution pools.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
198pub struct GpuExecutionQuerySnapshot {
199    /// Query-execution jobs active across all attached pools.
200    pub(crate) active_query_jobs: usize,
201    /// Highest exact concurrent query-execution count across attached pools.
202    pub peak_active_query_jobs: usize,
203}
204
205/// Cloneable aggregate counter for service-wide query execution concurrency.
206///
207/// Give the same tracker to each per-device pool through
208/// [`GpuExecutionPool::new_with_tracker`] to obtain a temporally correct peak.
209/// Summing device-local peaks is not equivalent because those peaks may have
210/// occurred at different times.
211#[derive(Debug, Clone, Default)]
212pub(crate) struct GpuExecutionQueryTracker {
213    counters: Arc<QueryCounters>,
214}
215
216impl GpuExecutionQueryTracker {
217    pub(crate) fn new() -> Self {
218        Self::default()
219    }
220
221    /// Returns the current and peak query concurrency across attached pools.
222    pub(crate) fn snapshot(&self) -> GpuExecutionQuerySnapshot {
223        let active_query_jobs = self.counters.active.load(Ordering::Acquire);
224        let peak_active_query_jobs = self.counters.peak.load(Ordering::Acquire).max(active_query_jobs);
225        GpuExecutionQuerySnapshot { active_query_jobs, peak_active_query_jobs }
226    }
227
228    fn start_query(&self) {
229        increment_with_peak(&self.counters.active, &self.counters.peak);
230    }
231
232    fn finish_query(&self) {
233        decrement(&self.counters.active);
234    }
235}
236
237/// Fixed-width, per-device executor for work that can block on the GPU.
238///
239/// Clones share one runtime and one set of counters. Dropping the last clone
240/// closes submissions and asks the supervisor to drop the runtime; an owner
241/// that needs bounded, observed teardown calls the explicit shutdown methods
242/// before that.
243#[derive(Clone)]
244pub(crate) struct GpuExecutionPool {
245    inner: Arc<GpuExecutionPoolInner>,
246    /// Query-service epoch this clone is bound to, when it was obtained
247    /// through an admitted context. Submissions are refused once the epoch
248    /// closes, and each spawned task retains the epoch so the service cannot
249    /// reach idle while a job it admitted is still running.
250    resource_epoch: Option<Arc<QueryServiceResourceEpoch>>,
251}
252
253impl GpuExecutionPool {
254    /// Enters this pool's Tokio runtime, so a future polled on a blocking
255    /// thread can register its timers with that runtime.
256    #[cfg(any(test, feature = "test-hooks"))]
257    pub(crate) fn enter_runtime(&self) -> EnterGuard<'_> {
258        self.inner.handle.enter()
259    }
260
261    /// Creates a pool with an independent query-concurrency tracker.
262    #[cfg(any(test, feature = "test-hooks"))]
263    pub(crate) fn new(device_ordinal: usize, worker_count: usize) -> Result<Self> {
264        Self::new_with_tracker(device_ordinal, worker_count, GpuExecutionQueryTracker::new())
265    }
266
267    /// Creates a pool attached to a caller-provided aggregate query tracker.
268    ///
269    /// Direct constructors use the documented default class partition:
270    /// `worker_count` lifecycle job slots and `worker_count` execution job slots.
271    /// Backend-owned pools pass explicit `A_d`/`A_d * S_d` capacities from
272    /// the device resource profile via [`Self::new_with_tracker_and_classes`].
273    #[cfg(any(test, feature = "test-hooks"))]
274    pub(crate) fn new_with_tracker(
275        device_ordinal: usize,
276        worker_count: usize,
277        query_tracker: GpuExecutionQueryTracker,
278    ) -> Result<Self> {
279        Self::new_with_tracker_and_classes(device_ordinal, worker_count, worker_count, worker_count, query_tracker)
280    }
281
282    /// Creates a pool with explicit lifecycle/execution job-slot capacities.
283    pub(crate) fn new_with_tracker_and_classes(
284        device_ordinal: usize,
285        worker_count: usize,
286        lifecycle_job_capacity: usize,
287        execution_job_capacity: usize,
288        query_tracker: GpuExecutionQueryTracker,
289    ) -> Result<Self> {
290        validate_worker_count(device_ordinal, worker_count)?;
291        for (field, capacity) in
292            [("root_job_capacity", lifecycle_job_capacity), ("aux_job_capacity", execution_job_capacity)]
293        {
294            if capacity == 0 {
295                return Err(QueryError::new(
296                    QueryErrorCode::InvalidConfiguration,
297                    "query_engine.gpu.execution_pool",
298                    "GPU execution pool job-class capacity must be greater than zero",
299                )
300                .with_fact("device_ordinal", device_ordinal)
301                .with_fact("field", field)
302                .with_fact("reason", "zero"));
303            }
304        }
305
306        let id = NEXT_POOL_ID.fetch_add(1, Ordering::Relaxed);
307        let next_worker_index = Arc::new(AtomicUsize::new(0));
308        let (handle, supervisor) = RuntimeSupervisor::start(device_ordinal, worker_count, id, next_worker_index)?;
309
310        let inner = Arc::new(GpuExecutionPoolInner {
311            id,
312            device_ordinal,
313            worker_count,
314            handle,
315            supervisor: Arc::clone(&supervisor),
316            submissions_closed: AtomicBool::new(false),
317            submission_gate: Mutex::new(()),
318            concurrency: Arc::new(Semaphore::new(worker_count)),
319            execution_workers: Arc::new(Semaphore::new(worker_count - RESERVED_LIFECYCLE_WORKERS)),
320            lifecycle_job_permits: Arc::new(Semaphore::new(lifecycle_job_capacity)),
321            execution_job_permits: Arc::new(Semaphore::new(execution_job_capacity)),
322            // Every admitted attempt can transition to exactly one release
323            // cleanup. Keep that bounded lifecycle tail separate from
324            // caller-submitted lifecycle jobs so an unrelated saturated
325            // lifecycle job cannot force CUDA-bound envelope destruction onto
326            // its caller.
327            // The task still uses the same fixed-width device runtime.
328            release_cleanup_permits: Arc::new(Semaphore::new(lifecycle_job_capacity)),
329            lifecycle_job_capacity,
330            execution_job_capacity,
331            counters: Arc::new(PoolCounters::default()),
332            drain: Arc::new(PoolDrain::default()),
333            query_tracker,
334            next_job_id: AtomicU64::new(1),
335        });
336        supervisor.register_process_exit(Arc::downgrade(&inner));
337        Ok(Self { inner, resource_epoch: None })
338    }
339
340    #[cfg(test)]
341    fn class_semaphore(&self, kind: GpuExecutionJobKind) -> &Arc<Semaphore> {
342        self.class_semaphore_for_class(kind.job_class())
343    }
344
345    fn class_semaphore_for_class(&self, class: GpuExecutionJobClass) -> &Arc<Semaphore> {
346        match class {
347            GpuExecutionJobClass::Lifecycle => &self.inner.lifecycle_job_permits,
348            GpuExecutionJobClass::Execution => &self.inner.execution_job_permits,
349        }
350    }
351
352    #[cfg(test)]
353    fn class_saturated_error(&self, kind: GpuExecutionJobKind) -> QueryError {
354        self.class_saturated_error_for_class(kind, kind.job_class())
355    }
356
357    fn class_saturated_error_for_class(&self, kind: GpuExecutionJobKind, class: GpuExecutionJobClass) -> QueryError {
358        QueryError::new(
359            QueryErrorCode::ServiceOverloaded,
360            "query_engine.gpu.execution_pool",
361            "GPU execution pool job-class capacity is saturated",
362        )
363        .with_fact("device_ordinal", self.inner.device_ordinal)
364        .with_fact("job_kind", kind.as_str())
365        .with_fact("job_class", class.as_str())
366        .with_fact("reason", "gpu_execution_pool_class_saturated")
367    }
368
369    fn release_cleanup_saturated_error(&self) -> QueryError {
370        QueryError::new(
371            QueryErrorCode::ServiceOverloaded,
372            "query_engine.gpu.execution_pool",
373            "GPU execution pool bounded release-cleanup capacity is saturated",
374        )
375        .with_fact("device_ordinal", self.inner.device_ordinal)
376        .with_fact("job_kind", GpuExecutionJobKind::Cleanup.as_str())
377        .with_fact("job_class", "release_cleanup")
378        .with_fact("reason", "gpu_execution_pool_release_cleanup_saturated")
379    }
380
381    /// Returns the process-global fallback pool for one CUDA device.
382    ///
383    /// A live fallback pool fixes the worker count for its device. Requesting a
384    /// different width is rejected, and no second executor is constructed for
385    /// the same device. Once the last clone is dropped, a later call may install
386    /// a new width.
387    #[cfg(any(test, feature = "test-hooks"))]
388    pub(crate) fn shared_for_device(device_ordinal: usize, worker_count: usize) -> Result<Self> {
389        validate_worker_count(device_ordinal, worker_count)?;
390
391        static SHARED_POOLS: OnceLock<Mutex<HashMap<usize, Weak<GpuExecutionPoolInner>>>> = OnceLock::new();
392        let shared_pools = SHARED_POOLS.get_or_init(|| Mutex::new(HashMap::new()));
393        let mut shared_pools = shared_pools.lock().map_err(|_error| {
394            QueryError::new(
395                QueryErrorCode::Internal,
396                SHARED_OPERATION,
397                "GPU execution pool registry mutex was poisoned",
398            )
399            .with_fact("device_ordinal", device_ordinal)
400            .with_fact("worker_count", worker_count)
401            .with_fact("reason", "registry_poisoned")
402        })?;
403
404        if let Some(inner) = shared_pools.get(&device_ordinal).and_then(Weak::upgrade) {
405            if inner.worker_count != worker_count {
406                return Err(QueryError::new(
407                    QueryErrorCode::InvalidPlan,
408                    SHARED_OPERATION,
409                    "CUDA device already has a shared GPU execution pool with a different worker count",
410                )
411                .with_fact("device_ordinal", device_ordinal)
412                .with_fact("existing_worker_count", inner.worker_count)
413                .with_fact("requested_worker_count", worker_count)
414                .with_fact("reason", "worker_count_conflict"));
415            }
416            return Ok(Self { inner, resource_epoch: None });
417        }
418
419        let pool = Self::new(device_ordinal, worker_count)?;
420        shared_pools.insert(device_ordinal, Arc::downgrade(&pool.inner));
421        Ok(pool)
422    }
423
424    /// Runs one job on the device pool and awaits its result.
425    ///
426    /// Dropping the returned future aborts the submitted task. Awaiting it
427    /// joins the task and converts task panic or cancellation into a structured
428    /// native error. Calls made from a job already running on this same pool
429    /// execute inline, which permits width-one setup/execution composition.
430    pub async fn run<F, T>(&self, kind: GpuExecutionJobKind, future: F) -> Result<T>
431    where
432        F: Future<Output = T> + Send + 'static,
433        T: Send + 'static,
434    {
435        self.run_observed(kind, RuntimeObservationHandle::disabled(), future).await
436    }
437
438    /// Runs one job while emitting host-side queue and worker lifecycle evidence.
439    ///
440    /// These events describe executor submission and host execution only. They
441    /// do not claim CUDA kernel duration.
442    pub(crate) async fn run_observed<F, T>(
443        &self,
444        kind: GpuExecutionJobKind,
445        observation: RuntimeObservationHandle,
446        future: F,
447    ) -> Result<T>
448    where
449        F: Future<Output = T> + Send + 'static,
450        T: Send + 'static,
451    {
452        self.clone().run_observed_scoped(kind, observation, move |_scope| future).await
453    }
454
455    /// Runs one observed job and passes its explicit job/worker correlation to
456    /// work that may acquire a service-stream lease.
457    pub fn run_observed_scoped<B, F, T>(
458        self,
459        kind: GpuExecutionJobKind,
460        observation: RuntimeObservationHandle,
461        build_future: B,
462    ) -> impl Future<Output = Result<T>> + Send + 'static
463    where
464        B: FnOnce(RuntimeStreamLeaseScope) -> F + Send + 'static,
465        F: Future<Output = T> + Send + 'static,
466        T: Send + 'static,
467    {
468        self.run_observed_scoped_in_class(kind, kind.job_class(), observation, build_future)
469    }
470
471    /// Runs one observed job through an explicit bounded resource class.
472    ///
473    /// Context setup retains its stable observation kind even when a later
474    /// fragment uses an auxiliary stream. Its class comes from the admitted
475    /// attempt's context reservation, and the observability label does not
476    /// supply it. Initial setup uses the lifecycle class; additional fragment
477    /// setup uses the execution class.
478    pub(crate) async fn run_observed_scoped_in_class<B, F, T>(
479        self,
480        kind: GpuExecutionJobKind,
481        class: GpuExecutionJobClass,
482        observation: RuntimeObservationHandle,
483        build_future: B,
484    ) -> Result<T>
485    where
486        B: FnOnce(RuntimeStreamLeaseScope) -> F + Send + 'static,
487        F: Future<Output = T> + Send + 'static,
488        T: Send + 'static,
489    {
490        self.check_resource_epoch(RUN_OPERATION)?;
491        // A job-class permit bounds every accepted awaited submission, in
492        // addition to detached `spawn` work. Acquiring asynchronously here
493        // would create an unbounded semaphore waiter queue containing arbitrary
494        // caller futures. Same-pool composition uses the same bounded permit
495        // below and executes inline only after that accounting succeeds.
496        let class_permit = Arc::clone(self.class_semaphore_for_class(class))
497            .try_acquire_owned()
498            .map_err(|_error| self.class_saturated_error_for_class(kind, class))?;
499        self.run_observed_scoped_with_class_permit(kind, class_permit, observation, build_future).await
500    }
501
502    /// Waits for a free slot of `class`, or returns the typed error of
503    /// `cancellation` once it is requested or its deadline passes. A free slot
504    /// is taken without a timer, so only a caller that has to wait needs a
505    /// Tokio context.
506    ///
507    /// Only work whose own count the class width already bounds may wait
508    /// here, so the waiter queue stays bounded: the admission envelope of
509    /// each placed attempt, whose lane caps them at the lifecycle width.
510    pub(crate) async fn wait_for_class_permit(
511        &self,
512        kind: GpuExecutionJobKind,
513        class: GpuExecutionJobClass,
514        cancellation: &ExecutionCancellation,
515    ) -> Result<OwnedSemaphorePermit> {
516        self.check_resource_epoch(RUN_OPERATION)?;
517        let semaphore = self.class_semaphore_for_class(class);
518        match Arc::clone(semaphore).try_acquire_owned() {
519            Ok(permit) => return Ok(permit),
520            Err(TryAcquireError::Closed) => return Err(self.class_saturated_error_for_class(kind, class)),
521            Err(TryAcquireError::NoPermits) => {},
522        }
523        tokio::select! {
524            biased;
525            permit = Arc::clone(self.class_semaphore_for_class(class)).acquire_owned() => {
526                permit.map_err(|_closed| self.class_saturated_error_for_class(kind, class))
527            },
528            error = cancellation.terminated(RUN_OPERATION) => Err(error),
529        }
530    }
531
532    /// Runs one observed job under a class permit the caller already holds.
533    pub(crate) async fn run_observed_scoped_with_class_permit<B, F, T>(
534        self,
535        kind: GpuExecutionJobKind,
536        class_permit: OwnedSemaphorePermit,
537        observation: RuntimeObservationHandle,
538        build_future: B,
539    ) -> Result<T>
540    where
541        B: FnOnce(RuntimeStreamLeaseScope) -> F + Send + 'static,
542        F: Future<Output = T> + Send + 'static,
543        T: Send + 'static,
544    {
545        let job = self.new_job_observation(kind, observation);
546        // A submission from a worker of this same pool runs inline on that
547        // worker. Spawning would wait for a second seat while the caller
548        // keeps its own, which deadlocks at width one and otherwise spends a
549        // worker on waiting. The class permit is still held so the inline
550        // run counts against its class like any other job.
551        if current_pool_id() == Some(self.inner.id) {
552            let _class_permit = class_permit;
553            let worker_id = job.started(current_worker_index());
554            let mut terminal = JobTerminalGuard::inline(Arc::clone(&job));
555            let scope = RuntimeStreamLeaseScope::default().with_gpu_worker(job.job_id.clone(), worker_id);
556            return match AssertUnwindSafe(build_future(scope)).catch_unwind().await {
557                Ok(value) => {
558                    terminal.terminal(GpuJobLifecycleState::Finished);
559                    Ok(value)
560                },
561                Err(_panic) => {
562                    terminal.terminal(GpuJobLifecycleState::Panicked);
563                    Err(job_panic_error(self.inner.device_ordinal, kind))
564                },
565            };
566        }
567
568        // The class permit is held before the job enters the worker runtime,
569        // so bounded child work can never consume a future root slot while it
570        // waits. Once accepted, a job may wait only behind the fixed-width
571        // worker semaphore; the number of such accepted jobs is bounded by
572        // its class capacity.
573        let job_guard = JobCounterGuard::new(
574            kind,
575            Arc::clone(&self.inner.counters),
576            Arc::clone(&self.inner.drain),
577            self.inner.query_tracker.clone(),
578            Arc::clone(&job),
579        );
580        let seats = self.inner.worker_seats();
581        let resource_epoch = self.resource_epoch.clone();
582        let task = self.inner.spawn_task(async move {
583            let _resource_epoch = resource_epoch;
584            let _class_permit = class_permit;
585            let seat = seats.occupy(kind).await;
586            run_counted_job_scoped(job_guard, seat, build_future).await
587        })?;
588
589        match AbortOnDropJoinHandle::new(task, Arc::clone(&job)).await {
590            Ok(Ok(value)) => Ok(value),
591            Ok(Err(_panic)) => {
592                job.terminal(GpuJobLifecycleState::Panicked);
593                Err(job_panic_error(self.inner.device_ordinal, kind))
594            },
595            Err(error) if error.is_cancelled() => {
596                job.cancellation_requested();
597                job.terminal(GpuJobLifecycleState::Cancelled);
598                Err(job_cancelled_error(self.inner.device_ordinal, kind, error))
599            },
600            Err(error) => {
601                job.terminal(GpuJobLifecycleState::Failed { error_code: QueryErrorCode::Internal.to_string() });
602                Err(job_join_panic_error(self.inner.device_ordinal, kind, error))
603            },
604        }
605    }
606
607    /// Detaches one job onto the device pool.
608    ///
609    /// This is intended for cleanup that must not block a serving worker. The
610    /// pool still accounts for the job, and its RAII counters are released if
611    /// it completes, panics, or is cancelled during runtime shutdown.
612    #[cfg(test)]
613    pub(crate) fn spawn<F>(&self, kind: GpuExecutionJobKind, future: F) -> Result<()>
614    where F: Future<Output = ()> + Send + 'static {
615        self.spawn_observed(kind, RuntimeObservationHandle::disabled(), future)
616    }
617
618    #[cfg(test)]
619    pub(crate) fn spawn_observed<F>(
620        &self,
621        kind: GpuExecutionJobKind,
622        observation: RuntimeObservationHandle,
623        future: F,
624    ) -> Result<()>
625    where
626        F: Future<Output = ()> + Send + 'static,
627    {
628        self.check_resource_epoch(RUN_OPERATION)?;
629        let class_permit = Arc::clone(self.class_semaphore(kind))
630            .try_acquire_owned()
631            .map_err(|_error| self.class_saturated_error(kind))?;
632        let job = self.new_job_observation(kind, observation);
633        let job_guard = JobCounterGuard::new(
634            kind,
635            Arc::clone(&self.inner.counters),
636            Arc::clone(&self.inner.drain),
637            self.inner.query_tracker.clone(),
638            job,
639        );
640        let seats = self.inner.worker_seats();
641        let resource_epoch = self.resource_epoch.clone();
642        self.inner.spawn_task(async move {
643            let _resource_epoch = resource_epoch;
644            let _class_permit = class_permit;
645            let seat = seats.occupy(kind).await;
646            let _ = run_counted_job(job_guard, seat, future).await;
647        })?;
648        Ok(())
649    }
650
651    /// Detaches one observed job and invokes `completion` after its terminal
652    /// job event, including cancellation or panic cleanup.
653    #[cfg(test)]
654    pub(crate) fn spawn_observed_with_completion<F, C>(
655        &self,
656        kind: GpuExecutionJobKind,
657        observation: RuntimeObservationHandle,
658        future: F,
659        completion: C,
660    ) -> Result<()>
661    where
662        F: Future<Output = ()> + Send + 'static,
663        C: FnOnce() + Send + 'static,
664    {
665        self.check_resource_epoch(RUN_OPERATION)?;
666        let class_permit = Arc::clone(self.class_semaphore(kind))
667            .try_acquire_owned()
668            .map_err(|_error| self.class_saturated_error(kind))?;
669        let job = self.new_job_observation(kind, observation);
670        let job_guard = JobCounterGuard::new_with_completion(
671            kind,
672            Arc::clone(&self.inner.counters),
673            Arc::clone(&self.inner.drain),
674            self.inner.query_tracker.clone(),
675            job,
676            Box::new(completion),
677        );
678        let seats = self.inner.worker_seats();
679        let resource_epoch = self.resource_epoch.clone();
680        self.inner.spawn_task(async move {
681            let _resource_epoch = resource_epoch;
682            let _class_permit = class_permit;
683            let seat = seats.occupy(kind).await;
684            let _ = run_counted_job(job_guard, seat, future).await;
685        })?;
686        Ok(())
687    }
688
689    /// Detaches one admission-owned CUDA cleanup task onto this device pool.
690    ///
691    /// A live admitted attempt has exactly one release path, so this uses a
692    /// dedicated `A_d`-sized permit set. Cleanup never waits in an unbounded
693    /// semaphore queue, and it never competes with arbitrary root submissions.
694    /// The cleanup still waits only behind the fixed-width device runtime.
695    pub(crate) fn spawn_release_cleanup_observed_with_completion<F, C>(
696        &self,
697        observation: RuntimeObservationHandle,
698        future: F,
699        completion: C,
700    ) -> Result<()>
701    where
702        F: Future<Output = ()> + Send + 'static,
703        C: FnOnce() + Send + 'static,
704    {
705        self.check_resource_epoch(RUN_OPERATION)?;
706        let release_permit = Arc::clone(&self.inner.release_cleanup_permits)
707            .try_acquire_owned()
708            .map_err(|_error| self.release_cleanup_saturated_error())?;
709        let kind = GpuExecutionJobKind::Cleanup;
710        let job = self.new_job_observation(kind, observation);
711        let job_guard = JobCounterGuard::new_with_completion(
712            kind,
713            Arc::clone(&self.inner.counters),
714            Arc::clone(&self.inner.drain),
715            self.inner.query_tracker.clone(),
716            job,
717            Box::new(completion),
718        );
719        let seats = self.inner.worker_seats();
720        let resource_epoch = self.resource_epoch.clone();
721        self.inner.spawn_task(async move {
722            let _resource_epoch = resource_epoch;
723            let _release_permit = release_permit;
724            let seat = seats.occupy(kind).await;
725            let _ = run_counted_job(job_guard, seat, future).await;
726        })?;
727        Ok(())
728    }
729
730    /// Submits one blocking worker onto the device pool without occupying an
731    /// async worker thread.
732    ///
733    /// Blocking source reads and other host-bound device work run on tokio's
734    /// blocking threads, never on the fixed-width async workers, so serving
735    /// workers cannot starve behind a decode. Admission still rides the same
736    /// bounded class permit as every other submission: saturation fails fast
737    /// with a typed error and does not queue unbounded waiters, and at most
738    /// `execution_job_capacity` blocking workers run concurrently, so no
739    /// second arbitrarily-sized thread pool appears behind this entry.
740    ///
741    /// The returned handle holds no pool permits. A coordinator blocked in
742    /// [`PoolBlockingWork::join`] therefore cannot starve the worker it waits
743    /// for. Capacity is released before completion is delivered, so a resolved
744    /// join implies the slot is reusable. Pool shutdown drops the completion
745    /// sender and the join reports a typed cancellation and does not hang.
746    pub(crate) fn submit_blocking<F, T>(
747        &self,
748        kind: GpuExecutionJobKind,
749        observation: RuntimeObservationHandle,
750        work: F,
751    ) -> Result<PoolBlockingWork<T>>
752    where
753        F: FnOnce() -> T + Send + 'static,
754        T: Send + 'static,
755    {
756        self.submit_blocking_inner(kind, observation, work, || {})
757    }
758
759    /// Submits blocking work with a terminal notification, including panic.
760    /// The callback runs after the outcome can be joined and does not hold a
761    /// pool slot; a coordinator can use it to receive workers in finish order.
762    pub(crate) fn submit_blocking_with_completion<F, T, C>(
763        &self,
764        kind: GpuExecutionJobKind,
765        observation: RuntimeObservationHandle,
766        work: F,
767        completion: C,
768    ) -> Result<PoolBlockingWork<T>>
769    where
770        F: FnOnce() -> T + Send + 'static,
771        T: Send + 'static,
772        C: FnOnce() + Send + 'static,
773    {
774        self.submit_blocking_inner(kind, observation, work, completion)
775    }
776
777    fn submit_blocking_inner<F, T, C>(
778        &self,
779        kind: GpuExecutionJobKind,
780        observation: RuntimeObservationHandle,
781        work: F,
782        completion: C,
783    ) -> Result<PoolBlockingWork<T>>
784    where
785        F: FnOnce() -> T + Send + 'static,
786        T: Send + 'static,
787        C: FnOnce() + Send + 'static,
788    {
789        self.check_resource_epoch(RUN_OPERATION)?;
790        let class = kind.job_class();
791        let class_permit = Arc::clone(self.class_semaphore_for_class(class))
792            .try_acquire_owned()
793            .map_err(|_error| self.class_saturated_error_for_class(kind, class))?;
794        let job = self.new_job_observation(kind, observation);
795        let guard = JobCounterGuard::new(
796            kind,
797            Arc::clone(&self.inner.counters),
798            Arc::clone(&self.inner.drain),
799            self.inner.query_tracker.clone(),
800            Arc::clone(&job),
801        );
802        let (sender, receiver) = mpsc::channel();
803        let device_ordinal = self.inner.device_ordinal;
804        let resource_epoch = self.resource_epoch.clone();
805        {
806            let _submission = self.inner.submission_gate.lock().map_err(|_| {
807                QueryError::new(QueryErrorCode::Internal, RUN_OPERATION, "GPU execution submission gate was poisoned")
808                    .with_fact("device_ordinal", device_ordinal)
809                    .with_fact("reason", "gpu_execution_pool_submission_gate_poisoned")
810            })?;
811            if self.inner.submissions_closed.load(Ordering::Acquire) {
812                return Err(pool_closed_error(device_ordinal));
813            }
814            let completion = BlockingCompletionNotify::new(completion);
815            self.inner.handle.spawn_blocking(move || {
816                let mut completion = completion;
817                let _resource_epoch = resource_epoch;
818                let mut guard = guard;
819                let _scope = guard.activate();
820                let outcome = match std::panic::catch_unwind(AssertUnwindSafe(work)) {
821                    Ok(value) => {
822                        guard.terminal.terminal(GpuJobLifecycleState::Finished);
823                        BlockingWorkOutcome::Completed(value)
824                    },
825                    Err(_) => {
826                        guard.terminal.terminal(GpuJobLifecycleState::Panicked);
827                        BlockingWorkOutcome::Panicked
828                    },
829                };
830                // The class slot, resource epoch, and job counters must be free
831                // before the completion reaches the waiter, so a resolved join
832                // implies the slot is reusable and no longer charged to this
833                // worker.
834                drop(class_permit);
835                drop(_resource_epoch);
836                drop(guard);
837                let _ = sender.send(outcome);
838                completion.notify();
839            });
840        }
841        Ok(PoolBlockingWork { receiver, job, device_ordinal, kind })
842    }
843
844    /// Allocates the job's identity and records it as enqueued. Job ids are
845    /// unique per pool instance, so a backend generation change yields fresh
846    /// ids and does not continue the previous pool's sequence.
847    fn new_job_observation(
848        &self,
849        kind: GpuExecutionJobKind,
850        observation: RuntimeObservationHandle,
851    ) -> Arc<JobObservation> {
852        let sequence = self.inner.next_job_id.fetch_add(1, Ordering::Relaxed);
853        let job = Arc::new(JobObservation {
854            observation,
855            job_id: RuntimeJobId::new(format!("gpu-pool-{}-job-{sequence}", self.inner.id)),
856            job_kind: kind,
857            device_ordinal: u32::try_from(self.inner.device_ordinal).unwrap_or(u32::MAX),
858            lifecycle: AtomicU8::new(JOB_LIFECYCLE_PENDING),
859        });
860        job.enqueued();
861        job
862    }
863
864    /// Returns device-local execution counters.
865    pub(crate) fn snapshot(&self) -> GpuExecutionPoolSnapshot {
866        let counters = &self.inner.counters;
867        let active_jobs = counters.active.load(Ordering::Acquire);
868        let queued_jobs = counters.queued.load(Ordering::Acquire);
869        let active_query_jobs = counters.active_queries.load(Ordering::Acquire);
870        GpuExecutionPoolSnapshot {
871            pool_id: self.inner.id,
872            device_ordinal: self.inner.device_ordinal,
873            worker_count: self.inner.worker_count,
874            root_job_capacity: self.inner.lifecycle_job_capacity,
875            aux_job_capacity: self.inner.execution_job_capacity,
876            root_jobs_available: self.inner.lifecycle_job_permits.available_permits(),
877            aux_jobs_available: self.inner.execution_job_permits.available_permits(),
878            active_jobs,
879            peak_active_jobs: counters.peak_active.load(Ordering::Acquire).max(active_jobs),
880            queued_jobs,
881            peak_queued_jobs: counters.peak_queued.load(Ordering::Acquire).max(queued_jobs),
882            active_query_jobs,
883            peak_active_query_jobs: counters.peak_active_queries.load(Ordering::Acquire).max(active_query_jobs),
884        }
885    }
886
887    /// Returns the aggregate query tracker attached to this pool.
888    #[cfg(test)]
889    pub(crate) fn query_tracker(&self) -> GpuExecutionQueryTracker {
890        self.inner.query_tracker.clone()
891    }
892
893    /// Prevents every later submission from being accepted by this pool.
894    ///
895    /// The submission gate makes the close transition linearizable with task
896    /// spawning: a task either enters the runtime before this call wins or is
897    /// rejected as closed.
898    pub(crate) fn close_submissions(&self) -> Result<()> {
899        self.inner.close_submissions()
900    }
901
902    /// Asks the owning supervisor to drop its runtime and cancel any async work
903    /// that did not drain after submissions were closed.
904    pub(crate) fn request_supervisor_exit(&self) -> Result<()> {
905        self.inner.request_supervisor_exit()
906    }
907
908    /// Waits for every accepted job guard to leave the queued or active state.
909    pub(crate) fn wait_for_drain_until(&self, deadline: Instant) -> Result<()> {
910        self.inner.wait_for_drain_until(deadline)
911    }
912
913    /// Waits for the runtime-owning supervisor to publish its terminal state.
914    pub(crate) fn wait_for_supervisor_exit_until(&self, deadline: Instant) -> Result<()> {
915        self.inner.supervisor.wait_for_exit_until(deadline)
916    }
917
918    /// Reclaims the exited supervisor thread and reports join failure as a
919    /// permanent cleanup failure.
920    pub(crate) fn join_supervisor(&self) -> Result<()> {
921        self.inner.supervisor.join()
922    }
923
924    /// Compatibility composition for cleanup coordinators that have not yet
925    /// split worker-pool cleanup into independently observed phases.
926    pub(crate) fn close_and_wait_until(&self, deadline: Instant) -> Result<()> {
927        self.close_submissions()?;
928        self.request_supervisor_exit()?;
929        let drain_result = self.wait_for_drain_until(deadline);
930        let exit_result = self.wait_for_supervisor_exit_until(deadline);
931        let exit_observed = match &exit_result {
932            Ok(()) => true,
933            Err(error) => matches!(
934                error.fact_value("reason"),
935                Some("runtime_supervisor_panicked" | "runtime_supervisor_build_failed")
936            ),
937        };
938        let join_result = if exit_observed { self.join_supervisor() } else { Ok(()) };
939
940        let mut first_deadline = None;
941        for result in [drain_result, exit_result, join_result] {
942            match result {
943                Ok(()) => {},
944                Err(error) if error.is_cleanup_deadline_elapsed() => {
945                    if first_deadline.is_none() {
946                        first_deadline = Some(error);
947                    }
948                },
949                Err(error) => return Err(error),
950            }
951        }
952        first_deadline.map_or(Ok(()), Err)
953    }
954
955    /// Terminal process-exit path. It has no timeout because returning while
956    /// the Tokio runtime can still execute a CUDA-owning job would let that
957    /// job run during libcudart teardown.
958    fn close_and_join_for_process_exit(&self) {
959        // Use the same gate as ordinary shutdown. Otherwise a submitter that
960        // already checked `submissions_closed` could still enqueue a task
961        // after this terminal path starts draining the runtime.
962        let submission = self.inner.submission_gate.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
963        self.inner.submissions_closed.store(true, Ordering::Release);
964        // Rejected submitters take the gate to report closure; holding it
965        // through the drain below would block them behind running jobs.
966        drop(submission);
967        self.inner.supervisor.request_exit_from_drop();
968        let mut state = self.inner.drain.changed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
969        while self.inner.counters.active.load(Ordering::Acquire) != 0
970            || self.inner.counters.queued.load(Ordering::Acquire) != 0
971        {
972            state = self.inner.drain.wake.wait(state).unwrap_or_else(std::sync::PoisonError::into_inner);
973        }
974        // Job guards take the drain lock in their `Drop`; the supervisor join
975        // below waits for those drops to finish.
976        drop(state);
977        self.inner.supervisor.join_for_process_exit();
978    }
979
980    /// Returns a clone whose submissions are additionally bound to
981    /// `resource_epoch`. Admitted contexts use this so jobs they submit fail
982    /// once their query service closes, while the device pool itself can
983    /// outlive that service.
984    pub(crate) fn with_resource_epoch(&self, resource_epoch: Arc<QueryServiceResourceEpoch>) -> Self {
985        Self { inner: Arc::clone(&self.inner), resource_epoch: Some(resource_epoch) }
986    }
987
988    /// Rejects a closing pool or a closed epoch before any permit is taken.
989    ///
990    /// Both reads are unsynchronized. Pool closure is re-checked under the
991    /// submission gate (`spawn_task` and the blocking submission path), which
992    /// is what makes "closed" authoritative. The epoch is checked only here;
993    /// the spawned task then retains it, so an epoch that closes after this
994    /// point waits for the job to finish and does not cancel it.
995    fn check_resource_epoch(&self, operation: &'static str) -> Result<()> {
996        if self.inner.submissions_closed.load(Ordering::Acquire) {
997            return Err(QueryError::new(QueryErrorCode::Cancelled, operation, "GPU execution pool is closing")
998                .with_fact("device_ordinal", self.inner.device_ordinal)
999                .with_fact("reason", "gpu_execution_pool_closing"));
1000        }
1001        self.resource_epoch.as_ref().map_or(Ok(()), |epoch| epoch.check_open(operation))
1002    }
1003}
1004
1005impl std::fmt::Debug for GpuExecutionPool {
1006    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1007        formatter.debug_struct("GpuExecutionPool").field("snapshot", &self.snapshot()).finish()
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests;