Skip to main content

dspk_core/compute/
target.rs

1//! Running work on a [`ComputeTarget`].
2//!
3//! Library code takes a [`Client`] (cubecl 0.11 resolves the runtime when the client is made, not
4//! through a generic); the CLI, Python bindings and the app pick a target once and get its client,
5//! or run a [`ComputeTask`] on it.
6//!
7//! **Devices are shut down at exit.** CubeCL runs each device on a runner thread that nothing joins,
8//! so the device is never destroyed and the GPU driver's own worker threads (NVIDIA's shader
9//! compiler) were still running while the process exited: an intermittent crash or hang after all
10//! the work was done (SIGSEGV in `libnvidia-glvkspirv`, "corrupted double-linked list" in
11//! `libnvidia-gpucomp`, with the main thread inside `exit()`). Every device a client is made for is
12//! recorded, and an exit handler, registered after each device is opened (so it runs before the
13//! handlers of the libraries and layers loaded until then), shuts them down: [`shutdown_devices`].
14//! That teardown can itself deadlock in the driver (`vkDestroyDevice` against those same threads),
15//! so it has a time limit, after which the process ends at once. Programs, which own their whole
16//! process, end with [`exit_now`] and skip the teardown altogether.
17//!
18//! **Autotune decisions and compiled kernels persist across processes** ([`configure_runtime`]):
19//! without it every process re-tuned (about a minute on a GPU, several with the CPU runtime) and
20//! recompiled every kernel.
21
22use std::ffi::c_int;
23#[cfg(target_os = "linux")]
24use std::ffi::c_void;
25use std::io::Write as _;
26use std::sync::Mutex;
27use std::time::Duration;
28
29use cubecl::device::{AmdDevice, CpuDevice, CudaDevice, WgpuDevice};
30use cubecl::device_handle::DeviceHandle;
31use cubecl::prelude::*;
32use cubecl::Device;
33
34use crate::device::{ComputeError, ComputeTarget};
35
36/// Environment variable: `0` turns off the compiled-kernel cache (autotune decisions still persist).
37pub const KERNEL_CACHE_ENV: &str = "DSPK_KERNEL_CACHE";
38
39/// Environment variable: directory for CubeCL's caches (autotune decisions, compiled kernels);
40/// default: the user cache directory.
41pub const CACHE_DIR_ENV: &str = "DSPK_CACHE_DIR";
42
43/// Sets CubeCL's runtime configuration once, before the first device opens: compiled kernels are
44/// cached (CubeCL's default is off), and the caches live in the user cache directory (or
45/// `DSPK_CACHE_DIR`) instead of the `target/` of whichever Cargo project the process runs in.
46/// A `cubecl.toml` or CubeCL's own environment variables still apply. Called by
47/// [`ComputeTarget::device`] and [`open_device`]; harmless to call again.
48pub fn configure_runtime() {
49    use cubecl::config::cache::CacheConfig;
50    use cubecl::config::{CubeClRuntimeConfig, RuntimeConfig};
51    static ONCE: std::sync::Once = std::sync::Once::new();
52    ONCE.call_once(|| {
53        let mut config = CubeClRuntimeConfig::from_current_dir().override_from_env();
54        config.compilation.cache = std::env::var(KERNEL_CACHE_ENV).map_or(true, |v| v.trim() != "0");
55        match std::env::var_os(CACHE_DIR_ENV) {
56            Some(dir) if !dir.is_empty() => config.environment.path = CacheConfig::Directory(dir.into()),
57            _ if matches!(config.environment.path, CacheConfig::Target) => config.environment.path = CacheConfig::Global,
58            _ => {}
59        }
60        // `false` when a configuration was already set (e.g. by the embedding application): keep it
61        let _ = CubeClRuntimeConfig::try_set(config);
62    });
63}
64
65/// Work to run on whichever runtime a [`ComputeTarget`] selects.
66pub trait ComputeTask {
67    type Output;
68    fn run(self, client: Client) -> Self::Output;
69}
70
71impl ComputeTarget {
72    /// The default device of this runtime, if it is compiled in.
73    pub fn device(self) -> Result<Device, ComputeError> {
74        configure_runtime();
75        Ok(match self.checked()? {
76            Self::Wgpu => Device::Wgpu(WgpuDevice::default()),
77            Self::Cpu => Device::Cpu(CpuDevice),
78            Self::Cuda => Device::Cuda(CudaDevice::default()),
79            Self::Hip => Device::Hip(AmdDevice::default()),
80        })
81    }
82
83    /// A client for this runtime's default device (recorded for [`shutdown_devices`]).
84    pub fn client(self) -> Result<Client, ComputeError> {
85        Ok(open_device(self.device()?))
86    }
87
88    /// Runs `task` with a client for this runtime's default device.
89    pub fn run<T: ComputeTask>(self, task: T) -> Result<T::Output, ComputeError> {
90        Ok(task.run(self.client()?))
91    }
92}
93
94/// Devices a client was made for, shut down at exit.
95static OPENED: Mutex<Vec<Device>> = Mutex::new(Vec::new());
96
97/// How long the exit handler waits for the wgpu devices to shut down. A teardown takes well under
98/// a second; past this the driver is deadlocked and the process ends without it.
99const EXIT_TEARDOWN_LIMIT: Duration = Duration::from_secs(10);
100
101unsafe extern "C" {
102    /// The C library's exit-handler registration (handlers run in reverse order of registration).
103    #[cfg(not(target_os = "linux"))]
104    fn atexit(handler: extern "C" fn()) -> c_int;
105    /// As `atexit`, and the handler receives the process's exit status (glibc).
106    #[cfg(target_os = "linux")]
107    fn on_exit(handler: extern "C" fn(c_int, *mut c_void), arg: *mut c_void) -> c_int;
108    /// Ends the process now: no exit handlers, no library destructors.
109    fn _exit(status: c_int) -> !;
110}
111
112/// Ends the process now with `status`, after flushing standard output and error: no exit handlers,
113/// no device teardown, so the GPU driver's threads cannot crash or deadlock it (the operating
114/// system releases the device). For a program's last line, once its results are written; a library
115/// must not call this, it would end the process of whoever loaded it.
116pub fn exit_now(status: i32) -> ! {
117    let _ = std::io::stdout().flush();
118    let _ = std::io::stderr().flush();
119    // SAFETY: `_exit` takes any status and does not return
120    unsafe { _exit(status) }
121}
122
123/// Runs `teardown`, ending the process with `status` if it has not returned within `limit`.
124fn teardown_within(limit: Duration, status: c_int, teardown: impl FnOnce()) {
125    let watchdog = std::thread::Builder::new().name("dspk-exit-limit".into()).spawn(move || {
126        std::thread::sleep(limit);
127        // SAFETY: as in `exit_now`
128        unsafe { _exit(status) }
129    });
130    // Without the thread (none left at exit) the teardown still runs, unguarded, as before
131    drop(watchdog);
132    teardown();
133}
134
135/// The exit handler: shuts down the opened wgpu devices only. They must be destroyed before the
136/// process exits (NVIDIA's Vulkan shader-compiler threads otherwise race the teardown). The other
137/// runners are left running: ending a runner thread drops CubeCL's kernel-cache connection during
138/// thread-local teardown, where turso can abort the process ("thread local panicked on drop",
139/// tracel-ai/cubecl#1981); the process exit ends them without their thread-local destructors.
140fn shutdown_at_exit(status: c_int) {
141    let is_wgpu = |device: &Device| matches!(device, Device::Wgpu(_));
142    if OPENED.lock().unwrap_or_else(|e| e.into_inner()).iter().any(is_wgpu) {
143        teardown_within(EXIT_TEARDOWN_LIMIT, status, || shutdown_devices_where(is_wgpu));
144    }
145}
146
147#[cfg(target_os = "linux")]
148extern "C" fn exit_handler(status: c_int, _arg: *mut c_void) {
149    shutdown_at_exit(status);
150}
151
152/// Without `on_exit` the status is not known: a process that hits the time limit ends with 0.
153#[cfg(not(target_os = "linux"))]
154extern "C" fn exit_handler() {
155    shutdown_at_exit(0);
156}
157
158/// Records `device`, newly opened, and registers [`shutdown_devices`] to run at exit. Called after
159/// the client exists: exit handlers run in reverse order of registration, and opening a device
160/// loads Vulkan layers that register their own (the validation layer destroys its device records
161/// in one), so ours must come later to run first. Registered again for each newly opened device,
162/// since a device reopened after a shutdown may load those layers again; running it more than once
163/// is harmless (the list is empty after the first run).
164fn opened(device: &Device) {
165    let mut devices = OPENED.lock().unwrap_or_else(|e| e.into_inner());
166    if devices.contains(device) {
167        return;
168    }
169    devices.push(device.clone());
170    drop(devices);
171    // SAFETY: registers a plain `extern "C"` function; failure (no slot left) only means the
172    // device is not shut down at exit, as before
173    unsafe {
174        #[cfg(target_os = "linux")]
175        on_exit(exit_handler, std::ptr::null_mut());
176        #[cfg(not(target_os = "linux"))]
177        atexit(exit_handler);
178    }
179}
180
181/// The client of `device`, recorded so that [`shutdown_devices`] shuts it down at exit. Use this
182/// instead of `Device::client` wherever a specific device is opened (e.g. one GPU of several).
183pub fn open_device(device: Device) -> Client {
184    configure_runtime();
185    let client = device.client();
186    opened(&device);
187    client
188}
189
190/// Shuts down every device a client was made for: their runner threads stop (queued work runs
191/// first) and the devices are destroyed, so the GPU driver's threads end before the process does.
192/// Runs at exit by itself; call it earlier to release the GPUs. Clients still alive keep their
193/// runner going (CubeCL waits up to 30 s for it, then leaves it), so drop them first. A later
194/// [`ComputeTarget::client`] opens the device again.
195pub fn shutdown_devices() {
196    shutdown_devices_where(|_| true);
197}
198
199/// [`shutdown_devices`] for the opened devices `select` accepts; the others stay recorded.
200fn shutdown_devices_where(select: impl Fn(&Device) -> bool) {
201    let devices: Vec<Device> = {
202        let mut opened = OPENED.lock().unwrap_or_else(|e| e.into_inner());
203        let (chosen, kept) = std::mem::take(&mut *opened).into_iter().partition(|d| select(d));
204        *opened = kept;
205        chosen
206    };
207    for device in devices {
208        // The runner is registered under the runtime's own id, not `Device`'s runtime-stamped one
209        DeviceHandle::<()>::shutdown(cubecl::RuntimeId::strip(device.to_id()));
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    struct RuntimeName;
218    impl ComputeTask for RuntimeName {
219        type Output = String;
220        fn run(self, client: Client) -> String {
221            client.name().to_string()
222        }
223    }
224
225    /// Threads of this process (Linux).
226    fn threads() -> usize {
227        std::fs::read_dir("/proc/self/task").map(|d| d.count()).unwrap_or(0)
228    }
229
230    /// Shutting the devices down stops their runner (and driver) threads; a later client opens the
231    /// device again.
232    #[test]
233    #[cfg(target_os = "linux")]
234    fn shutdown_stops_the_device_threads() {
235        let Some(target) = ComputeTarget::for_tests().into_iter().find(|t| *t != ComputeTarget::Cpu) else { return };
236        let client = target.client().unwrap();
237        let data: Vec<u8> = (0..64u8).collect();
238        let handle = client.create(cubecl::bytes::Bytes::from_bytes_vec(data.clone()));
239        assert_eq!(client.read_one_unchecked(handle).to_vec(), data);
240        drop(client);
241        let open = threads();
242        shutdown_devices();
243        let closed = threads();
244        assert!(closed < open, "{open} threads with the device open, {closed} after shutdown");
245        let again = target.client().unwrap();
246        assert!(!again.name().is_empty());
247    }
248
249    /// The body of the two exit tests, in a process of its own (see `in_child`).
250    #[test]
251    fn exit_child() {
252        match std::env::var("DSPK_EXIT_TEST").as_deref() {
253            Ok("now") => {
254                print!("flushed");
255                exit_now(7);
256            }
257            Ok("limit") => teardown_within(Duration::from_millis(100), 3, || std::thread::sleep(Duration::from_secs(600))),
258            _ => {}
259        }
260    }
261
262    fn in_child(mode: &str) -> std::process::Output {
263        std::process::Command::new(std::env::current_exe().unwrap())
264            .args(["--exact", "compute::target::tests::exit_child", "--nocapture"])
265            .env("DSPK_EXIT_TEST", mode)
266            .output()
267            .unwrap()
268    }
269
270    #[test]
271    fn exit_now_flushes_and_keeps_the_status() {
272        let out = in_child("now");
273        assert_eq!(out.status.code(), Some(7));
274        assert!(String::from_utf8_lossy(&out.stdout).contains("flushed"));
275    }
276
277    /// A teardown that never returns (a driver deadlock) still ends the process, with its status.
278    #[test]
279    fn a_stuck_teardown_ends_the_process_with_its_status() {
280        let start = std::time::Instant::now();
281        assert_eq!(in_child("limit").status.code(), Some(3));
282        assert!(start.elapsed() < Duration::from_secs(60), "took {:?}", start.elapsed());
283    }
284
285    #[test]
286    fn runs_on_available_targets_and_refuses_missing_ones() {
287        for target in ComputeTarget::for_tests() {
288            assert!(!target.run(RuntimeName).unwrap().is_empty());
289        }
290        for target in ComputeTarget::ALL.into_iter().filter(|t| !t.is_available()) {
291            assert!(matches!(target.run(RuntimeName), Err(ComputeError::Unavailable(..))));
292        }
293    }
294}