Skip to main content

burn_cubecl/
backend.rs

1use crate::{CubeDevice, tensor::CubeTensor};
2use burn_backend::cubecl::dtype_to_storage_type;
3use burn_backend::{
4    Backend, BackendGraph, BackendTypes, DTypeUsage, DTypeUsageSet, ExecutionError,
5    MemoryPoolUsage, ProfileDuration, ProfileOptions, ProfileToken, SlicedPoolReport, TensorData,
6    profile_with_tokens,
7};
8use burn_std::{BoolStore, DType, id::StreamId, quantization::quantizable};
9use cubecl::device::DeviceId;
10use cubecl::{
11    MemoryPoolKind, MemoryScope,
12    client::{Client, ProfileWindow},
13    features::{MmaConfig, TypeUsage},
14    ir::ElemType,
15    server::{ProfileError, ProfilingToken, ServerError},
16};
17
18#[cfg(not(feature = "fusion"))]
19use burn_backend::tensor::{BoolTensor, FloatTensor, IntTensor, QuantizedTensor};
20#[cfg(not(feature = "fusion"))]
21use burn_ir::{BackendIr, TensorHandle};
22
23/// Whether the runtime can hold a quantized dtype's scales, which `dtype_to_storage_type` misses
24/// because it doesn't see the scheme's scale levels. Non-quantized dtypes always pass.
25fn qfloat_params_usable(client: &Client, dtype: DType) -> bool {
26    let DType::QFloat(scheme) = dtype else {
27        return true;
28    };
29
30    quantizable(&scheme)
31        && client
32            .properties()
33            .type_usage(ElemType::from_scale_dtype(scheme.scale_dtype()))
34            .is_superset(TypeUsage::Buffer | TypeUsage::Conversion)
35}
36
37/// Turn a cubecl server error into a backend [`ExecutionError`], keeping the
38/// one distinction a caller acts on: whether the device is poisoned.
39pub(crate) fn server_err(err: ServerError) -> ExecutionError {
40    match err.is_device_poisoned() {
41        true => ExecutionError::device_poisoned(format!("{err}")),
42        false => ExecutionError::with_context(format!("{err}")),
43    }
44}
45
46/// Turn a cubecl graph-capture error into a backend [`ExecutionError`].
47fn graph_err(err: impl core::fmt::Display) -> ExecutionError {
48    ExecutionError::WithContext {
49        reason: format!("{err}"),
50    }
51}
52
53/// Turn a cubecl profiling error into a backend [`ExecutionError`].
54fn profile_err(err: ProfileError) -> ExecutionError {
55    ExecutionError::WithContext {
56        reason: format!("{err}"),
57    }
58}
59
60/// A window a kernel-stamping runtime could not measure.
61///
62/// A runtime that stamps the stream (CUDA, HIP) answers a window nothing ran
63/// in with two stamps and nothing between them. One that stamps kernels (wgpu)
64/// has no query set for it and refuses it as [`ProfileError::NotMeasured`] —
65/// but it refuses **two** cases with one error, and cubecl says so where the
66/// refusal is raised: a window that dispatched nothing, and a window whose
67/// work never landed in a timestamped pass. The second is a kernel that ran.
68///
69/// So this resolves to no measurement rather than to a zero. Zero is the
70/// fastest duration there is, and a caller comparing two windows — which is
71/// what a profiling scope is for — would take the one that could not be
72/// measured as the quicker of the two. `None` is already how every reader of a
73/// [`ProfileDuration`] spells an absence, and the error it replaces carried
74/// exactly that meaning.
75fn empty_window() -> ProfileDuration {
76    ProfileDuration::new_device_time_maybe(async move { None })
77}
78
79/// A captured launch sequence, tagged with the device it was captured on.
80///
81/// `cubecl::client::Graph` is self-contained: it owns a handle to the device it recorded on and
82/// replays there no matter what device the caller names. Every cubecl runtime is one backend now,
83/// so a graph captured on CUDA and replayed with a wgpu device is no longer a variant mismatch the
84/// dispatch layer catches — without this tag it would replay, silently, on the wrong device.
85#[derive(Clone, Debug)]
86pub struct CubeGraph {
87    graph: cubecl::client::Graph,
88    device: CubeDevice,
89}
90
91/// Tensor backend that compiles just-in-time for whichever runtime its device
92/// names.
93#[derive(new)]
94pub struct CubeBackend;
95
96impl BackendTypes for CubeBackend {
97    type Device = CubeDevice;
98
99    type FloatTensorPrimitive = CubeTensor;
100    type IntTensorPrimitive = CubeTensor;
101    type BoolTensorPrimitive = CubeTensor;
102    type QuantizedTensorPrimitive = CubeTensor;
103
104    type GraphPrimitive = CubeGraph;
105}
106
107impl Backend for CubeBackend {
108    fn name(device: &Self::Device) -> String {
109        let client = device.client();
110        format!("cubecl<{}>", client.name())
111    }
112
113    fn seed(_device: &Self::Device, seed: u64) {
114        cubek::random::seed(seed);
115    }
116
117    fn ad_enabled(_device: &Self::Device) -> bool {
118        false
119    }
120
121    fn sync(device: &Self::Device) -> Result<(), ExecutionError> {
122        let client = device.client();
123        // A barrier plus the device's own fault, and nothing more: a launch
124        // failure lives on the buffers the launch never wrote and surfaces on
125        // the read of one of them, so it is not this sync's to report.
126        // `client.sync_buffers` is the same barrier plus a check of named
127        // tensors, for a caller that wants both.
128        futures_lite::future::block_on(client.sync()).map_err(server_err)
129    }
130
131    fn profile<O: Send + 'static>(
132        device: &Self::Device,
133        options: ProfileOptions,
134        func: impl FnOnce() -> O + Send,
135    ) -> Result<(O, ProfileDuration), ExecutionError> {
136        // Not cubecl's bracketed `profile`: that one runs the closure on the
137        // device's runner while holding the device, so a closure waiting on
138        // another thread's call to the same device — a data loader building
139        // its batch there, say — would never get it back. The split window
140        // opens and closes on the stream without holding anything between.
141        profile_with_tokens::<Self, O>(device, options, func)
142    }
143
144    fn profile_start(device: &Self::Device) -> Result<Option<ProfileToken>, ExecutionError> {
145        let client = device.client();
146        client
147            .profile_start()
148            .map(|window| {
149                Some(ProfileToken {
150                    id: window.token.id,
151                    opened_on: window.stream_id.value,
152                })
153            })
154            .map_err(profile_err)
155    }
156
157    fn profile_end(
158        device: &Self::Device,
159        token: ProfileToken,
160        _options: ProfileOptions,
161    ) -> Result<ProfileDuration, ExecutionError> {
162        // Nothing is queued past the window here: every launch reaches the
163        // stream as it is made, so there is nothing for the flush option to
164        // force out.
165        let client = device.client();
166        // Closed on the stream it was opened on, which the token carries —
167        // not on the calling thread's, which need not be the same one.
168        let window = ProfileWindow {
169            stream_id: StreamId {
170                value: token.opened_on,
171            },
172            token: ProfilingToken { id: token.id },
173        };
174        match client.profile_end(window) {
175            Ok(duration) => Ok(duration),
176            Err(ProfileError::NotMeasured { .. }) => Ok(empty_window()),
177            Err(err) => Err(profile_err(err)),
178        }
179    }
180
181    /// Dropped on the stream it was opened on, without recording an end —
182    /// cubecl returns the start event to its pool and nothing is measured.
183    fn profile_abandon(device: &Self::Device, token: ProfileToken) {
184        let window = ProfileWindow {
185            stream_id: StreamId {
186                value: token.opened_on,
187            },
188            token: ProfilingToken { id: token.id },
189        };
190        device.client().profile_abandon(window);
191    }
192
193    fn graph_prepare(device: &Self::Device) -> Result<(), ExecutionError> {
194        let client = device.client();
195        client.graph_prepare().map_err(graph_err)
196    }
197
198    fn graph_start_capture(device: &Self::Device) -> Result<(), ExecutionError> {
199        let client = device.client();
200        client.start_capture().map_err(graph_err)
201    }
202
203    fn graph_stop_capture(device: &Self::Device) -> Result<BackendGraph<Self>, ExecutionError> {
204        let client = device.client();
205        let graph = client.stop_capture().map_err(graph_err)?;
206
207        Ok(CubeGraph {
208            graph,
209            device: device.clone(),
210        })
211    }
212
213    unsafe fn graph_replay(
214        device: &Self::Device,
215        graph: &BackendGraph<Self>,
216    ) -> Result<(), ExecutionError> {
217        // The replay goes to the device the graph was captured on whatever is passed here, so a
218        // caller naming a different one gets told rather than silently served the other device.
219        if &graph.device != device {
220            return Err(ExecutionError::WithContext {
221                reason: format!(
222                    "The graph was captured on {:?} and cannot replay on {device:?}",
223                    graph.device
224                ),
225            });
226        }
227
228        // cubecl's `Graph::replay` blocks on the enqueue and reports what the
229        // enqueue said; a failure also leaves the graph's write set carrying
230        // it, so a read of those buffers keeps failing until a replay lands.
231        //
232        // Safety: the buffer-liveness and stream-ordering obligations are the
233        // caller's, forwarded verbatim from this method's own contract.
234        unsafe { graph.graph.replay() }.map_err(graph_err)
235    }
236
237    fn memory_persistent_allocations<
238        Output: Send,
239        Input: Send,
240        Func: Fn(Input) -> Output + Send,
241    >(
242        device: &Self::Device,
243        input: Input,
244        func: Func,
245    ) -> Output {
246        let client = device.client();
247        client.memory_persistent_allocation(input, func)
248    }
249
250    fn memory_cleanup(device: &Self::Device) {
251        let client = device.client();
252        // Refused only while a stream records a graph, which keeps its pages
253        // for the replay. The memory is released by the next cleanup instead.
254        let _ = client.memory_cleanup();
255    }
256
257    fn memory_pool_report(device: &Self::Device) -> Option<Vec<SlicedPoolReport>> {
258        let report = device.client().memory_report(MemoryScope::Device);
259
260        // One entry per pool that carves pages — the pool sized to what it
261        // serves, the pools a growth left behind, and the metadata pool —
262        // in the order allocations are routed through them. Pools whose
263        // allocations own their page have no page size to report.
264        Some(
265            report
266                .streams
267                .iter()
268                .flat_map(|stream| &stream.pools.dynamic)
269                .filter_map(|pool| {
270                    let page_size = match pool.kind {
271                        MemoryPoolKind::Sliced { page_size, .. } => page_size,
272                        MemoryPoolKind::Adaptive { page_size, .. } => page_size,
273                        _ => return None,
274                    };
275                    Some(SlicedPoolReport {
276                        page_size,
277                        pages: pool.pages,
278                        pages_peak: pool.pages_peak,
279                        largest_alloc: pool.largest_alloc,
280                    })
281                })
282                .collect(),
283        )
284    }
285
286    fn memory_pool_usage(device: &Self::Device) -> Option<MemoryPoolUsage> {
287        let usage = device.client().memory_report(MemoryScope::Device).usage();
288
289        Some(MemoryPoolUsage {
290            number_allocs: usage.number_allocs,
291            bytes_in_use: usage.bytes_in_use,
292            bytes_padding: usage.bytes_padding,
293            bytes_reserved: usage.bytes_reserved,
294        })
295    }
296
297    fn staging<'a, Iter>(data: Iter, device: &Self::Device)
298    where
299        Iter: Iterator<Item = &'a mut TensorData>,
300    {
301        let client = device.client();
302        TensorData::with_bytes_mut(data, |bytes| client.staging(bytes.into_iter(), false));
303    }
304
305    fn supports_dtype(device: &Self::Device, dtype: DType) -> bool {
306        // Right now no cubecl backend actually works with native bool, even if
307        // the `TypeUsage` might indicate otherwise.
308        if let DType::Bool(BoolStore::Native) = dtype {
309            return false;
310        }
311        let client = device.client();
312
313        if !qfloat_params_usable(&client, dtype) {
314            return false;
315        }
316
317        let type_usage = client.properties().type_usage(dtype_to_storage_type(dtype));
318        // Same as `TypeUsage::all_scalar()`, but we make the usage explicit here
319        type_usage.is_superset(
320            TypeUsage::Buffer
321                | TypeUsage::Conversion
322                | TypeUsage::Arithmetic
323                | TypeUsage::DotProduct,
324        )
325    }
326
327    fn dtype_usage(device: &Self::Device, dtype: DType) -> DTypeUsageSet {
328        // Right now no cubecl backend actually works with native bool, even if
329        // the `TypeUsage` might indicate otherwise.
330        if let DType::Bool(BoolStore::Native) = dtype {
331            return DTypeUsageSet::empty();
332        }
333        let client = device.client();
334
335        if !qfloat_params_usable(&client, dtype) {
336            return DTypeUsageSet::empty();
337        }
338
339        let props = client.properties();
340        let storage = dtype_to_storage_type(dtype);
341        let usage = props.type_usage(storage);
342
343        let mut out = DTypeUsageSet::new();
344
345        if usage.is_superset(TypeUsage::Buffer | TypeUsage::Conversion) {
346            out |= DTypeUsage::Storage;
347        }
348
349        if usage.contains(TypeUsage::Arithmetic) {
350            out |= DTypeUsage::Arithmetic;
351        }
352
353        let has_mma = |cfg: &MmaConfig| {
354            cfg.a_type == storage || cfg.b_type == storage || cfg.cd_type == storage
355        };
356        if props.features.matmul.cmma.iter().any(has_mma)
357            || props.features.matmul.mma.iter().any(has_mma)
358        {
359            out |= DTypeUsage::Accelerated;
360        }
361
362        out
363    }
364
365    fn device_count(type_id: u16) -> usize {
366        CubeDevice::enumerate(DeviceId::new(type_id, 0)).len()
367    }
368
369    fn flush(device: &Self::Device) -> Result<(), ExecutionError> {
370        let client = device.client();
371        client.flush().map_err(server_err)
372    }
373}
374
375impl core::fmt::Debug for CubeBackend {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        f.write_str("CubeCLBackend")
378    }
379}
380
381impl Clone for CubeBackend {
382    fn clone(&self) -> Self {
383        Self::new()
384    }
385}
386
387impl Default for CubeBackend {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393#[cfg(not(feature = "fusion"))]
394impl BackendIr for CubeBackend {
395    type Handle = CubeTensor;
396
397    fn float_tensor(handle: TensorHandle<Self::Handle>) -> FloatTensor<Self> {
398        handle.handle
399    }
400
401    fn int_tensor(handle: TensorHandle<Self::Handle>) -> IntTensor<Self> {
402        handle.handle
403    }
404
405    fn bool_tensor(handle: TensorHandle<Self::Handle>) -> BoolTensor<Self> {
406        handle.handle
407    }
408
409    fn quantized_tensor(handle: TensorHandle<Self::Handle>) -> QuantizedTensor<Self> {
410        handle.handle
411    }
412
413    fn float_tensor_handle(tensor: FloatTensor<Self>) -> Self::Handle {
414        tensor
415    }
416
417    fn int_tensor_handle(tensor: IntTensor<Self>) -> Self::Handle {
418        tensor
419    }
420
421    fn bool_tensor_handle(tensor: BoolTensor<Self>) -> Self::Handle {
422        tensor
423    }
424
425    fn quantized_tensor_handle(tensor: QuantizedTensor<Self>) -> Self::Handle {
426        tensor
427    }
428}