Skip to main content

cubecl_server/command/
base.rs

1//! One unit of work against the device.
2//!
3//! Every operation a backend server exposes that touches memory or launches a
4//! kernel goes through a [`Command`]: it pairs the context holding the
5//! compiled kernels with the streams the operation was resolved against, and
6//! resolving is what orders the current stream behind whichever streams own
7//! the buffers it was handed.
8//!
9//! Everything here is the same whichever driver is underneath — the allocation
10//! and reclaim policy, when the drop queue may be flushed, what a copy stages.
11//! The four calls that are not are [`Driver`](super::Driver)'s.
12
13use super::{CopyLayout, DeviceResource, DeviceStream, Driver, Staging};
14use crate::id::KernelId;
15use crate::memory_management::drop_queue::Fence;
16use crate::memory_management::{
17    InstallMemoryPoolsError, ManagedMemoryHandle, MemoryAllocationMode, MemoryConfiguration,
18    MemoryHandle, MemoryReport, MemoryUsage,
19};
20use crate::server::{BufferBinding, CopyDescriptor, Handle, IoError, LaunchError, ServerError};
21use crate::stream::ResolvedStreams;
22use alloc::boxed::Box;
23use alloc::vec;
24use alloc::vec::Vec;
25use cubecl_common::{bytes::Bytes, device::ServiceId};
26use cubecl_environment::backtrace::BackTrace;
27use cubecl_environment::future::DynFut;
28use cubecl_environment::stream::StreamId;
29use cubecl_ir::MemoryDeviceProperties;
30
31/// One unit of work against the device: the context that holds its compiled
32/// kernels, and the streams it was resolved against.
33///
34/// Built per operation rather than held, because resolving is what orders the
35/// current stream behind whichever streams own the buffers it was given.
36pub struct Command<'a, D: Driver> {
37    ctx: &'a mut D::Context,
38    streams: ResolvedStreams<'a, D::Backend>,
39    /// The service issuing the command: what the handles it allocates are
40    /// stamped with.
41    service: ServiceId,
42}
43
44impl<'a, D: Driver> Command<'a, D> {
45    /// A command against `ctx` over the streams `streams` resolved.
46    pub fn new(
47        ctx: &'a mut D::Context,
48        streams: ResolvedStreams<'a, D::Backend>,
49        service: ServiceId,
50    ) -> Self {
51        Self {
52            ctx,
53            streams,
54            service,
55        }
56    }
57
58    /// The stream this command is issued on.
59    ///
60    /// The one part of the resolution a backend reaches for directly: the
61    /// driver calls take a stream, and this is the one they take.
62    pub fn stream(&mut self) -> &mut D::Stream {
63        self.streams.current()
64    }
65
66    /// The device allocation `binding` names, resolved on the stream that
67    /// created it rather than the current one.
68    ///
69    /// # Errors
70    ///
71    /// [`IoError::StorageHandleNotFound`] when the binding names no live allocation.
72    pub fn resource(&mut self, binding: BufferBinding) -> Result<DeviceResource<D>, IoError> {
73        self.streams
74            .get(&binding.stream)
75            .device_memory()
76            .get_resource(binding.memory, binding.offset_start, binding.offset_end)
77    }
78
79    /// The current stream's device memory usage.
80    pub fn memory_usage(&mut self) -> MemoryUsage {
81        self.streams.current().device_memory().memory_usage()
82    }
83
84    /// Structured per-pool report of the current stream's device memory.
85    pub fn memory_report(&mut self) -> MemoryReport {
86        self.streams.current().device_memory().memory_report()
87    }
88
89    /// Release everything the current stream is holding that nothing still
90    /// needs.
91    pub fn memory_cleanup(&mut self) {
92        let stream = self.streams.current();
93        // Deferred frees sit in the drop queue until a fenced flush, so an
94        // explicit cleanup must drain it first or the pools still see those
95        // slices as live. Skipped mid-capture: a host sync aborts the capture,
96        // and the capture path drains the queue itself. The cleanups below stay
97        // safe mid-capture: `cleanup` defers all frees while a capture is
98        // active.
99        if !stream.capturing().is_recording() {
100            let signal = stream.signal();
101            stream.drop_queue().drain(|| D::Stream::fence(signal));
102            // The info cache's buffers are live slices in the dynamic pools;
103            // an explicit cleanup exists to leave those pools empty (e.g. for
104            // a rebuild sized to the next workload), so every entry not pinned
105            // by a live graph goes too. Skipped while recording for the same
106            // reason the drain is: an entry the recording has not touched yet
107            // would come back as a fresh allocation inside the capture window,
108            // which is illegal.
109            stream.info_cache().clear_unpinned();
110        }
111        let (stream, failures) = self.streams.current_and_failures();
112        stream.device_memory().cleanup(true, failures);
113        stream.host_memory().cleanup(true, failures);
114    }
115
116    /// Flush the current stream's drop queue, freeing what the device is
117    /// known to be done with.
118    ///
119    /// Deferred while the stream records a graph — the flush records a fence
120    /// on the capturing stream, which corrupts the recording — and the window
121    /// drains the queue itself when it closes. The rule lives here, on the one
122    /// path a server has to the queue, so no call site can rebuild the flush
123    /// without the guard.
124    pub fn flush_drops(&mut self) {
125        let stream = self.streams.current();
126        if stream.capturing().is_recording() {
127            return;
128        }
129        let signal = stream.signal();
130        stream.drop_queue().flush(|| D::Stream::fence(signal));
131    }
132
133    /// Set the [`MemoryAllocationMode`] for the current stream.
134    pub fn allocation_mode(&mut self, mode: MemoryAllocationMode) {
135        self.streams.current().device_memory().mode(mode)
136    }
137
138    /// Rebuild the current stream's device pools with a new layout, keeping
139    /// the old one when something is still live in them.
140    ///
141    /// # Errors
142    ///
143    /// [`InstallMemoryPoolsError::PoolsInUse`] when the rebuild was refused.
144    pub fn install_memory_pools(
145        &mut self,
146        config: MemoryConfiguration,
147        props: &MemoryDeviceProperties,
148    ) -> Result<(), InstallMemoryPoolsError> {
149        let (stream, failures) = self.streams.current_and_failures();
150        stream
151            .device_memory()
152            .install_pools(config, props, failures)
153    }
154
155    /// Allocate `size` bytes of device memory on the current stream.
156    ///
157    /// # Errors
158    ///
159    /// [`IoError::BufferTooBig`] when no device could ever fit it, and
160    /// whatever the allocator reports when a reclaim-and-retry still cannot.
161    pub fn reserve(&mut self, size: u64) -> Result<ManagedMemoryHandle, IoError> {
162        let (stream, failures) = self.streams.current_and_failures();
163        match stream.device_memory().reserve(size, failures) {
164            Ok(handle) => Ok(handle),
165            Err(err) if !err.may_succeed_after_reclaim() => Err(err),
166            // Reclaim this stream's memory and retry once; only a failure after
167            // that is reported. Without the retry a transient peak becomes a
168            // never-initialized handle whose every downstream use fails.
169            Err(err) => {
170                log::warn!("device allocation of {size} B failed ({err}); reclaiming and retrying");
171                self.memory_cleanup();
172                let (stream, failures) = self.streams.current_and_failures();
173                stream.device_memory().reserve(size, failures)
174            }
175        }
176    }
177
178    /// The current stream's cursor.
179    pub fn cursor(&self) -> u64 {
180        self.streams.cursor
181    }
182
183    /// Allocate `size` bytes of device memory and a handle naming it.
184    ///
185    /// # Errors
186    ///
187    /// Whatever the allocation or the bind reports.
188    pub fn empty(&mut self, size: u64) -> Result<Handle, IoError> {
189        let handle = Handle::new(self.service, self.streams.current, size);
190        let reserved = self.reserve(size)?;
191        self.bind(reserved, handle.memory.clone())?;
192
193        Ok(handle)
194    }
195
196    /// Give `reserved`'s storage to `new`, so handles issued against `new`
197    /// resolve to it.
198    ///
199    /// # Errors
200    ///
201    /// [`IoError`] when the reservation has no initialized storage to give.
202    pub fn bind(
203        &mut self,
204        reserved: ManagedMemoryHandle,
205        new: ManagedMemoryHandle,
206    ) -> Result<(), IoError> {
207        let cursor = self.cursor();
208        let (stream, failures) = self.streams.current_and_failures();
209        stream.device_memory().bind(reserved, new, cursor, failures)
210    }
211
212    /// `size` bytes of host memory, pinned when the pool can serve it.
213    ///
214    /// Pinned pages transfer by DMA without a bounce, but they are scarce, so
215    /// an exhausted pool falls back to the heap rather than failing: this
216    /// always answers with a buffer of the size asked for.
217    pub fn reserve_cpu(&mut self, size: usize, origin: Option<StreamId>) -> Bytes {
218        self.reserve_pinned(size, origin)
219            .unwrap_or_else(|| Bytes::from_bytes_vec(vec![0; size]))
220    }
221
222    /// `size` bytes of pinned host memory, or `None` when the pool cannot
223    /// serve it.
224    fn reserve_pinned(&mut self, size: usize, origin: Option<StreamId>) -> Option<Bytes> {
225        let (stream, failures) = match origin {
226            Some(id) => self.streams.get_and_failures(&id),
227            None => self.streams.current_and_failures(),
228        };
229        let handle = stream.host_memory().reserve(size as u64, failures).ok()?;
230
231        let binding = MemoryHandle::binding(handle);
232        let resource = stream
233            .host_memory()
234            .get_resource(binding.clone(), None, None)
235            .ok()?;
236
237        // SAFETY: the binding has initialized memory for at least `size` bytes,
238        // and `resource` is what the manager just resolved it to.
239        Some(unsafe { D::pinned_bytes(binding, resource, size) })
240    }
241
242    /// Copy each descriptor's device memory back to the host, resolving once
243    /// the copies have landed.
244    ///
245    /// The copies are enqueued before the future is returned; awaiting it
246    /// waits on the fence that follows them.
247    ///
248    /// # Errors
249    ///
250    /// [`IoError::UnsupportedStrides`] for a layout the driver cannot copy,
251    /// and whatever the fence reports when the stream itself failed.
252    pub fn read_async(
253        &mut self,
254        descriptors: Vec<CopyDescriptor>,
255    ) -> impl Future<Output = Result<Vec<Bytes>, ServerError>> + Send + use<D> {
256        let held = descriptors
257            .iter()
258            .map(|descriptor| descriptor.handle.clone())
259            .collect::<Vec<_>>();
260        let result = self.copies_to_bytes(descriptors);
261        let fence = D::Stream::fence(self.streams.current().signal());
262
263        async move {
264            let synced = fence.wait();
265            // The bindings kept the source allocations alive across the copies;
266            // the fence above is what says they are done being read.
267            core::mem::drop(held);
268
269            synced?;
270            result.map_err(Into::into)
271        }
272    }
273
274    /// Copy each descriptor's device memory into a fresh host buffer.
275    fn copies_to_bytes(&mut self, descriptors: Vec<CopyDescriptor>) -> Result<Vec<Bytes>, IoError> {
276        let mut result = Vec::with_capacity(descriptors.len());
277
278        for descriptor in descriptors {
279            match self.copy_to_bytes(descriptor, None) {
280                Ok(bytes) => result.push(bytes),
281                Err(err) => {
282                    // The buffers collected so far are the destinations of
283                    // copies already enqueued: dropping them hands their
284                    // pinned slices back to a pool whose reuse is gated on
285                    // the refcount alone, while the device is still writing
286                    // them. The fence `read_async` records to cover exactly
287                    // this does not exist yet on the error path, so record
288                    // one here and wait it out before the partial set drops.
289                    if !result.is_empty() {
290                        D::Stream::fence(self.streams.current().signal()).sync();
291                    }
292                    return Err(err);
293                }
294            }
295        }
296
297        Ok(result)
298    }
299
300    /// Copy one descriptor's device memory into a fresh host buffer.
301    fn copy_to_bytes(
302        &mut self,
303        descriptor: CopyDescriptor,
304        stream_id: Option<StreamId>,
305    ) -> Result<Bytes, IoError> {
306        let num_bytes = descriptor.shape.iter().product::<usize>() * descriptor.elem_size;
307        let mut bytes = self.reserve_cpu(num_bytes, stream_id);
308        self.write_to_cpu(descriptor, &mut bytes, stream_id)?;
309
310        Ok(bytes)
311    }
312
313    /// Enqueue a copy of `descriptor`'s device memory into `bytes`.
314    ///
315    /// # Errors
316    ///
317    /// [`IoError::UnsupportedStrides`] for a layout that is not pitched
318    /// row-major, [`IoError::StorageHandleNotFound`] for a binding that names no live
319    /// allocation, and the driver's refusal to copy.
320    pub fn write_to_cpu(
321        &mut self,
322        descriptor: CopyDescriptor,
323        bytes: &mut Bytes,
324        stream_id: Option<StreamId>,
325    ) -> Result<(), IoError> {
326        let CopyDescriptor {
327            handle: binding,
328            shape,
329            strides,
330            elem_size,
331        } = descriptor;
332        // Nothing to copy for an empty tensor, and `bytes` has no real backing
333        // for the driver to write into — a dangling zero-size buffer.
334        // Its strides may contain zeros, so skip copy-layout validation too.
335        if bytes.is_empty() {
336            return Ok(());
337        }
338
339        let layout = CopyLayout::of(&shape, &strides, elem_size)?;
340        let resource = self.resource(binding)?;
341        let stream = match stream_id {
342            Some(id) => self.streams.get(&id),
343            None => self.streams.current(),
344        };
345
346        // SAFETY: `resource` is a live device allocation the manager just
347        // resolved, `bytes` was sized for this copy, and the caller awaits the
348        // fence `read_async` records before reading it back.
349        unsafe { D::copy_to_host(&resource, &layout, bytes, stream) }
350    }
351
352    /// Enqueue a copy of `data` into the device memory `descriptor` names.
353    ///
354    /// # Errors
355    ///
356    /// [`IoError::UnsupportedStrides`] for a layout that is not pitched
357    /// row-major, [`IoError::StorageHandleNotFound`] for a binding that names no live
358    /// allocation, and the driver's refusal to copy.
359    pub fn write_to_gpu(&mut self, descriptor: CopyDescriptor, data: Bytes) -> Result<(), IoError> {
360        let CopyDescriptor {
361            handle: binding,
362            shape,
363            strides,
364            elem_size,
365        } = descriptor;
366        let size = data.len();
367
368        // An empty tensor (a zero dim in its shape) has nothing to copy. Bail
369        // before validating its potentially zero strides or staging: the zero-size
370        // staging buffer has no real backing (a dangling pointer), and a 2D copy
371        // would still transfer `width_bytes` from it when only the leading dims are zero.
372        if size == 0 {
373            return Ok(());
374        }
375
376        let layout = CopyLayout::of(&shape, &strides, elem_size)?;
377        let resource = self.resource(binding)?;
378        let staging = Staging::of(size, data.property());
379
380        let data = match staging.through_pinned {
381            true => {
382                // Pinned staging is a DMA optimization, not a requirement, so
383                // an exhausted pinned pool falls back to a plain heap buffer
384                // rather than failing the write — the same answer `reserve_cpu`
385                // gives for the same condition. File-backed data still lands in
386                // real memory before the driver reads it asynchronously, which
387                // is the half of the staging that is mandatory.
388                let mut buffer = self
389                    .reserve_pinned(size, None)
390                    .unwrap_or_else(|| Bytes::from_bytes_vec(vec![0; size]));
391                data.copy_into(&mut buffer);
392                buffer
393            }
394            false => data,
395        };
396
397        let current = self.streams.current();
398
399        // SAFETY: `resource` is a live device allocation, `data` is a valid
400        // host buffer, and either the drop queue or the capture window below
401        // keeps it alive for as long as the device reads it.
402        unsafe { D::copy_to_device(&resource, &layout, &data, current)? };
403
404        if current.capturing().is_recording() {
405            // A copy recorded into a graph is not executed now but re-read on
406            // every replay: the node keeps the raw host pointer, so the bytes
407            // ride the window onto the graph rather than the drop queue —
408            // which frees them when the window closes, exactly when the graph
409            // starts needing them.
410            current.capturing().retain_host(data);
411        } else {
412            current.drop_queue().push(data);
413            if staging.flush_after || current.drop_queue().should_flush() {
414                let signal = current.signal();
415                current.drop_queue().flush(|| D::Stream::fence(signal));
416            }
417        }
418
419        Ok(())
420    }
421
422    /// Allocate device memory for `data` and enqueue the copy into it.
423    ///
424    /// # Errors
425    ///
426    /// Whatever the allocation or the copy reports.
427    pub fn create_with_data(&mut self, data: &[u8]) -> Result<Handle, IoError> {
428        let mut staging =
429            self.reserve_pinned(data.len(), None)
430                .ok_or_else(|| IoError::Unknown {
431                    backtrace: BackTrace::capture(),
432                    description: "Unable to reserve pinned memory".into(),
433                })?;
434
435        staging.copy_from_slice(data);
436
437        let handle = self.empty(staging.len() as u64)?;
438
439        self.write_to_gpu(
440            CopyDescriptor {
441                handle: handle.clone().binding(),
442                shape: [data.len()].into(),
443                strides: [1].into(),
444                elem_size: 1,
445            },
446            staging,
447        )?;
448
449        Ok(handle)
450    }
451
452    /// Wait for everything already enqueued on the current stream to finish.
453    ///
454    /// # Errors
455    ///
456    /// The fault the barrier reveals, when the stream itself failed.
457    pub fn sync(&mut self) -> DynFut<Result<(), ServerError>> {
458        let fence = D::Stream::fence(self.streams.current().signal());
459
460        Box::pin(async move { fence.wait() })
461    }
462
463    /// Enqueue an already-compiled kernel on the current stream.
464    ///
465    /// # Errors
466    ///
467    /// The driver's refusal to enqueue the launch, returned whether or not a
468    /// profile is open. An open profile is not a reason to hold the failure
469    /// here: the caller's write scope is what claims the buffers the launch
470    /// never wrote, and the caller invalidates every open profile on the same
471    /// path, so keeping it would lose the claim and duplicate the report.
472    pub fn kernel(
473        &mut self,
474        kernel: KernelId,
475        count: (u32, u32, u32),
476        args: &mut D::LaunchArgs,
477    ) -> Result<(), LaunchError> {
478        let stream = self.streams.current();
479        let result = D::launch(self.ctx, stream, kernel, count, args);
480
481        // A fenced flush during capture would abort it; defer until the capture
482        // ends, when the deferred staging buffers are reclaimed.
483        if !stream.capturing().is_recording() && stream.drop_queue().should_flush() {
484            let signal = stream.signal();
485            stream.drop_queue().flush(|| D::Stream::fence(signal));
486        }
487
488        result
489    }
490}