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}