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//!
15//! **Autotune decisions and compiled kernels persist across processes** ([`configure_runtime`]):
16//! without it every process re-tuned (about a minute on a GPU, several with the CPU runtime) and
17//! recompiled every kernel.
18
19use std::sync::Mutex;
20
21use cubecl::device::{AmdDevice, CpuDevice, CudaDevice, WgpuDevice};
22use cubecl::device_handle::DeviceHandle;
23use cubecl::prelude::*;
24use cubecl::Device;
25
26use crate::device::{ComputeError, ComputeTarget};
27
28/// Environment variable: `0` turns off the compiled-kernel cache (autotune decisions still persist).
29pub const KERNEL_CACHE_ENV: &str = "DSPK_KERNEL_CACHE";
30
31/// Environment variable: directory for CubeCL's caches (autotune decisions, compiled kernels);
32/// default: the user cache directory.
33pub const CACHE_DIR_ENV: &str = "DSPK_CACHE_DIR";
34
35/// Sets CubeCL's runtime configuration once, before the first device opens: compiled kernels are
36/// cached (CubeCL's default is off), and the caches live in the user cache directory (or
37/// `DSPK_CACHE_DIR`) instead of the `target/` of whichever Cargo project the process runs in.
38/// A `cubecl.toml` or CubeCL's own environment variables still apply. Called by
39/// [`ComputeTarget::device`] and [`open_device`]; harmless to call again.
40pub fn configure_runtime() {
41    use cubecl::config::cache::CacheConfig;
42    use cubecl::config::{CubeClRuntimeConfig, RuntimeConfig};
43    static ONCE: std::sync::Once = std::sync::Once::new();
44    ONCE.call_once(|| {
45        let mut config = CubeClRuntimeConfig::from_current_dir().override_from_env();
46        config.compilation.cache = std::env::var(KERNEL_CACHE_ENV).map_or(true, |v| v.trim() != "0");
47        match std::env::var_os(CACHE_DIR_ENV) {
48            Some(dir) if !dir.is_empty() => config.environment.path = CacheConfig::Directory(dir.into()),
49            _ if matches!(config.environment.path, CacheConfig::Target) => config.environment.path = CacheConfig::Global,
50            _ => {}
51        }
52        // `false` when a configuration was already set (e.g. by the embedding application): keep it
53        let _ = CubeClRuntimeConfig::try_set(config);
54    });
55}
56
57/// Work to run on whichever runtime a [`ComputeTarget`] selects.
58pub trait ComputeTask {
59    type Output;
60    fn run(self, client: Client) -> Self::Output;
61}
62
63impl ComputeTarget {
64    /// The default device of this runtime, if it is compiled in.
65    pub fn device(self) -> Result<Device, ComputeError> {
66        configure_runtime();
67        Ok(match self.checked()? {
68            Self::Wgpu => Device::Wgpu(WgpuDevice::default()),
69            Self::Cpu => Device::Cpu(CpuDevice),
70            Self::Cuda => Device::Cuda(CudaDevice::default()),
71            Self::Hip => Device::Hip(AmdDevice::default()),
72        })
73    }
74
75    /// A client for this runtime's default device (recorded for [`shutdown_devices`]).
76    pub fn client(self) -> Result<Client, ComputeError> {
77        Ok(open_device(self.device()?))
78    }
79
80    /// Runs `task` with a client for this runtime's default device.
81    pub fn run<T: ComputeTask>(self, task: T) -> Result<T::Output, ComputeError> {
82        Ok(task.run(self.client()?))
83    }
84}
85
86/// Devices a client was made for, shut down at exit.
87static OPENED: Mutex<Vec<Device>> = Mutex::new(Vec::new());
88
89unsafe extern "C" {
90    /// The C library's exit-handler registration (handlers run in reverse order of registration).
91    fn atexit(handler: extern "C" fn()) -> std::ffi::c_int;
92}
93
94/// The exit handler: shuts down the opened wgpu devices only. They must be destroyed before the
95/// process exits (NVIDIA's Vulkan shader-compiler threads otherwise race the teardown). The other
96/// runners are left running: ending a runner thread drops CubeCL's kernel-cache connection during
97/// thread-local teardown, where turso can abort the process ("thread local panicked on drop",
98/// tracel-ai/cubecl#1981); the process exit ends them without their thread-local destructors.
99extern "C" fn shutdown_at_exit() {
100    shutdown_devices_where(|device| matches!(device, Device::Wgpu(_)));
101}
102
103/// Records `device`, newly opened, and registers [`shutdown_devices`] to run at exit. Called after
104/// the client exists: exit handlers run in reverse order of registration, and opening a device
105/// loads Vulkan layers that register their own (the validation layer destroys its device records
106/// in one), so ours must come later to run first. Registered again for each newly opened device,
107/// since a device reopened after a shutdown may load those layers again; running it more than once
108/// is harmless (the list is empty after the first run).
109fn opened(device: &Device) {
110    let mut devices = OPENED.lock().unwrap_or_else(|e| e.into_inner());
111    if devices.contains(device) {
112        return;
113    }
114    devices.push(device.clone());
115    drop(devices);
116    // SAFETY: registers a plain `extern "C"` function; failure (no slot left) only means the
117    // device is not shut down at exit, as before
118    unsafe {
119        atexit(shutdown_at_exit);
120    }
121}
122
123/// The client of `device`, recorded so that [`shutdown_devices`] shuts it down at exit. Use this
124/// instead of `Device::client` wherever a specific device is opened (e.g. one GPU of several).
125pub fn open_device(device: Device) -> Client {
126    configure_runtime();
127    let client = device.client();
128    opened(&device);
129    client
130}
131
132/// Shuts down every device a client was made for: their runner threads stop (queued work runs
133/// first) and the devices are destroyed, so the GPU driver's threads end before the process does.
134/// Runs at exit by itself; call it earlier to release the GPUs. Clients still alive keep their
135/// runner going (CubeCL waits up to 30 s for it, then leaves it), so drop them first. A later
136/// [`ComputeTarget::client`] opens the device again.
137pub fn shutdown_devices() {
138    shutdown_devices_where(|_| true);
139}
140
141/// [`shutdown_devices`] for the opened devices `select` accepts; the others stay recorded.
142fn shutdown_devices_where(select: impl Fn(&Device) -> bool) {
143    let devices: Vec<Device> = {
144        let mut opened = OPENED.lock().unwrap_or_else(|e| e.into_inner());
145        let (chosen, kept) = std::mem::take(&mut *opened).into_iter().partition(|d| select(d));
146        *opened = kept;
147        chosen
148    };
149    for device in devices {
150        // The runner is registered under the runtime's own id, not `Device`'s runtime-stamped one
151        DeviceHandle::<()>::shutdown(cubecl::RuntimeId::strip(device.to_id()));
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    struct RuntimeName;
160    impl ComputeTask for RuntimeName {
161        type Output = String;
162        fn run(self, client: Client) -> String {
163            client.name().to_string()
164        }
165    }
166
167    /// Threads of this process (Linux).
168    fn threads() -> usize {
169        std::fs::read_dir("/proc/self/task").map(|d| d.count()).unwrap_or(0)
170    }
171
172    /// Shutting the devices down stops their runner (and driver) threads; a later client opens the
173    /// device again.
174    #[test]
175    #[cfg(target_os = "linux")]
176    fn shutdown_stops_the_device_threads() {
177        let Some(target) = ComputeTarget::for_tests().into_iter().find(|t| *t != ComputeTarget::Cpu) else { return };
178        let client = target.client().unwrap();
179        let data: Vec<u8> = (0..64u8).collect();
180        let handle = client.create(cubecl::bytes::Bytes::from_bytes_vec(data.clone()));
181        assert_eq!(client.read_one_unchecked(handle).to_vec(), data);
182        drop(client);
183        let open = threads();
184        shutdown_devices();
185        let closed = threads();
186        assert!(closed < open, "{open} threads with the device open, {closed} after shutdown");
187        let again = target.client().unwrap();
188        assert!(!again.name().is_empty());
189    }
190
191    #[test]
192    fn runs_on_available_targets_and_refuses_missing_ones() {
193        for target in ComputeTarget::for_tests() {
194            assert!(!target.run(RuntimeName).unwrap().is_empty());
195        }
196        for target in ComputeTarget::ALL.into_iter().filter(|t| !t.is_available()) {
197            assert!(matches!(target.run(RuntimeName), Err(ComputeError::Unavailable(..))));
198        }
199    }
200}