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 calls that are not are [`Driver`](super::Driver)'s.
12
13use super::stream_copies::StreamCopies;
14use super::{CopyLayout, DeviceResource, DeviceStream, Driver, Staging};
15use crate::id::KernelId;
16use crate::memory_management::drop_queue::Fence;
17use crate::memory_management::relocation::{RelocatingStreams, RelocationNeed, RelocationReason};
18use crate::memory_management::{Cleanup, release_or_leak};
19use crate::memory_management::{
20 ManagedMemoryHandle, MemoryAllocationMode, MemoryHandle, PageGuard, PageUpdate,
21 StreamMemoryReport,
22};
23use crate::server::{BufferBinding, CopyDescriptor, Handle, IoError, LaunchError, ServerError};
24use crate::storage::ManagedResource;
25use crate::stream::ResolvedStreams;
26use crate::stream::TouchedPage;
27use alloc::boxed::Box;
28use alloc::vec;
29use alloc::vec::Vec;
30use cubecl_common::{bytes::Bytes, device::ServiceId};
31use cubecl_environment::backtrace::BackTrace;
32use cubecl_environment::future::DynFut;
33use cubecl_environment::stream::StreamId;
34
35/// One unit of work against the device: the context that holds its compiled
36/// kernels, and the streams it was resolved against.
37///
38/// Built per operation rather than held, because resolving is what orders the
39/// current stream behind whichever streams own the buffers it was given.
40pub struct Command<'a, D: Driver> {
41 ctx: &'a mut D::Context,
42 streams: ResolvedStreams<'a, D::Backend>,
43 /// The service issuing the command: what the handles it allocates are
44 /// stamped with.
45 service: ServiceId,
46}
47
48impl<'a, D: Driver> Command<'a, D> {
49 /// A command against `ctx` over the streams `streams` resolved.
50 pub fn new(
51 ctx: &'a mut D::Context,
52 streams: ResolvedStreams<'a, D::Backend>,
53 service: ServiceId,
54 ) -> Self {
55 Self {
56 ctx,
57 streams,
58 service,
59 }
60 }
61
62 /// The stream this command is issued on.
63 ///
64 /// The one part of the resolution a backend reaches for directly: the
65 /// driver calls take a stream, and this is the one they take.
66 pub fn stream(&mut self) -> &mut D::Stream {
67 self.streams.current()
68 }
69
70 /// The device allocation `binding` names, resolved on the stream that
71 /// created it rather than the current one.
72 ///
73 /// While the current stream records a graph, the page the allocation
74 /// sits on is remembered: the graph replays against the address resolved
75 /// here, so it guards that page once it seals.
76 ///
77 /// # Errors
78 ///
79 /// [`IoError::StorageHandleNotFound`] when the binding names no live allocation.
80 pub fn resource(&mut self, binding: BufferBinding) -> Result<DeviceResource<D>, IoError> {
81 self.streams
82 .current()
83 .capturing()
84 .touch(binding.stream, binding.memory.descriptor().location());
85 self.streams
86 .get(&binding.stream)
87 .device_memory()
88 .get_resource(binding.memory, binding.offset_start, binding.offset_end)
89 }
90
91 /// The device allocation `binding` names, for a caller outside the server
92 /// that keeps its raw address: it holds the allocation and a guard on its
93 /// page for as long as it lives.
94 ///
95 /// # Errors
96 ///
97 /// [`IoError::StorageHandleNotFound`] when the binding names no live allocation.
98 pub fn managed_resource(
99 &mut self,
100 binding: BufferBinding,
101 ) -> Result<ManagedResource<DeviceResource<D>>, IoError> {
102 self.streams
103 .get(&binding.stream)
104 .device_memory()
105 .managed_resource(binding.memory, binding.offset_start, binding.offset_end)
106 }
107
108 /// A guard on every page in `touched`, each looked up in the memory of the
109 /// stream that owns it.
110 pub fn guard_pages(&mut self, touched: &[TouchedPage]) -> Vec<PageGuard> {
111 touched
112 .iter()
113 .filter_map(|page| {
114 self.streams
115 .get(&page.stream)
116 .device_memory()
117 .guard(page.location)
118 })
119 .collect()
120 }
121
122 /// Everything the current stream's device memory holds, pool by pool.
123 pub fn memory_report(&mut self) -> StreamMemoryReport {
124 StreamMemoryReport {
125 stream: self.streams.current,
126 pools: self.streams.current().device_memory().memory_report(),
127 auxiliary: Vec::new(),
128 }
129 }
130
131 /// Release everything the current stream is holding that nothing still
132 /// needs.
133 ///
134 /// # Errors
135 ///
136 /// Refused while any stream records a graph: releasing memory waits on
137 /// the device, and a wait on a stream that records aborts its capture.
138 pub fn memory_cleanup(&mut self) -> Result<(), ServerError> {
139 self.refuse_while_recording()?;
140 let stream = self.streams.current();
141 // Deferred frees sit in the drop queue until a fenced flush, so an
142 // explicit cleanup must drain it first or the pools still see those
143 // slices as live.
144 let signal = stream.signal();
145 stream.drop_queue().drain(|| D::Stream::fence(signal));
146 // The info cache's buffers are live slices in the dynamic pools; an
147 // explicit cleanup exists to leave those pools empty, so every entry
148 // not pinned by a live graph goes too.
149 stream.info_cache().clear_unpinned();
150
151 RelocatingStreams::reclaim(self)?;
152 let (stream, failures) = self.streams.current_and_failures();
153 stream.host_memory().cleanup(Cleanup::Explicit, failures);
154 Ok(())
155 }
156
157 /// Flush the current stream's drop queue, freeing what the device is
158 /// known to be done with.
159 ///
160 /// Deferred while the stream records a graph — the flush records a fence
161 /// on the capturing stream, which corrupts the recording — and the window
162 /// drains the queue itself when it closes. The rule lives here, on the one
163 /// path a server has to the queue, so no call site can rebuild the flush
164 /// without the guard.
165 pub fn flush_drops(&mut self) {
166 let stream = self.streams.current();
167 if stream.capturing().is_recording() {
168 return;
169 }
170 let signal = stream.signal();
171 stream.drop_queue().flush(|| D::Stream::fence(signal));
172 }
173
174 /// Set the [`MemoryAllocationMode`] for the current stream.
175 pub fn allocation_mode(&mut self, mode: MemoryAllocationMode) {
176 self.streams.current().device_memory().mode(mode)
177 }
178
179 /// Allocate `size` bytes of device memory on the current stream.
180 ///
181 /// # Errors
182 ///
183 /// [`IoError::BufferTooBig`] when no device could ever fit it, and
184 /// whatever the allocator reports when a reclaim-and-retry still cannot.
185 pub fn reserve(&mut self, size: u64) -> Result<ManagedMemoryHandle, IoError> {
186 let update = self.streams.current().capturing().page_update();
187 if update == PageUpdate::Allow {
188 self.relocate_when_wanted();
189 }
190
191 let (stream, failures) = self.streams.current_and_failures();
192 match stream.device_memory().reserve(size, update, failures) {
193 Ok(handle) => Ok(handle),
194 // The recording now misses whatever this memory was for.
195 Err(err @ IoError::PageUpdateForbidden { .. }) => {
196 stream.capturing().fail(err.clone().into());
197 Err(err)
198 }
199 Err(err) if update != PageUpdate::Allow || !err.may_succeed_after_reclaim() => Err(err),
200 // Reclaim and retry once; only a failure after that is reported.
201 // Without the retry a transient peak becomes a never-initialized
202 // handle whose every downstream use fails.
203 Err(err) => {
204 log::warn!("device allocation of {size} B failed ({err}); reclaiming and retrying");
205 self.make_room(&err);
206 let (stream, failures) = self.streams.current_and_failures();
207 stream
208 .device_memory()
209 .reserve(size, PageUpdate::Allow, failures)
210 }
211 }
212 }
213
214 /// Get back what the reservation that failed with `err` needs: a slot for
215 /// a new page size when the arena is full, memory otherwise.
216 fn make_room(&mut self, err: &IoError) {
217 match err {
218 // Only a relocation that may allocate empties the outdated pools
219 // whatever room the current pages have.
220 IoError::PageSizesExhausted { .. } => {
221 RelocatingStreams::relocate(self, RelocationReason::ArenaFull)
222 }
223 // No stream records (the caller checked), which is the only
224 // refusal a cleanup has.
225 _ => {
226 let _ = self.memory_cleanup();
227 }
228 }
229 }
230
231 /// Give `memory` `size` bytes of device memory on the current stream.
232 ///
233 /// # Errors
234 ///
235 /// Returns an [`IoError`] when the memory cannot be reserved or bound, e.g. when the device
236 /// is out of memory. The caller records it on `memory`, which has no binding to carry it.
237 pub fn initialize_memory(
238 &mut self,
239 memory: ManagedMemoryHandle,
240 size: u64,
241 ) -> Result<(), IoError> {
242 let reserved = match self.reserve(size) {
243 Ok(reserved) => reserved,
244 // The recording already failed on it, and `stop_capture` reports
245 // that: the handle stays unbound, and whatever uses it belongs to
246 // a recording that will not seal.
247 Err(IoError::PageUpdateForbidden { .. }) => return Ok(()),
248 Err(err) => return Err(err),
249 };
250 self.bind(reserved, memory)
251 }
252
253 /// The current stream's cursor.
254 pub fn cursor(&self) -> u64 {
255 self.streams.cursor
256 }
257
258 /// Allocate `size` bytes of device memory and a handle naming it.
259 ///
260 /// # Errors
261 ///
262 /// Whatever the allocation or the bind reports.
263 pub fn empty(&mut self, size: u64) -> Result<Handle, IoError> {
264 let handle = Handle::new(self.service, self.streams.current, size);
265 let reserved = self.reserve(size)?;
266 self.bind(reserved, handle.memory.clone())?;
267
268 Ok(handle)
269 }
270
271 /// Give `reserved`'s storage to `new`, so handles issued against `new`
272 /// resolve to it.
273 ///
274 /// # Errors
275 ///
276 /// [`IoError`] when the reservation has no initialized storage to give.
277 pub fn bind(
278 &mut self,
279 reserved: ManagedMemoryHandle,
280 new: ManagedMemoryHandle,
281 ) -> Result<(), IoError> {
282 let cursor = self.cursor();
283 let (stream, failures) = self.streams.current_and_failures();
284 stream.device_memory().bind(reserved, new, cursor, failures)
285 }
286
287 /// `size` bytes of host memory, pinned when the pool can serve it.
288 ///
289 /// Pinned pages transfer by DMA without a bounce, but they are scarce, so
290 /// an exhausted pool falls back to the heap rather than failing: this
291 /// always answers with a buffer of the size asked for.
292 pub fn reserve_cpu(&mut self, size: usize, origin: Option<StreamId>) -> Bytes {
293 self.reserve_pinned(size, origin)
294 .unwrap_or_else(|| Bytes::from_bytes_vec(vec![0; size]))
295 }
296
297 /// `size` bytes of pinned host memory, or `None` when the pool cannot
298 /// serve it.
299 fn reserve_pinned(&mut self, size: usize, origin: Option<StreamId>) -> Option<Bytes> {
300 let (stream, failures) = match origin {
301 Some(id) => self.streams.get_and_failures(&id),
302 None => self.streams.current_and_failures(),
303 };
304 // A stream recording a graph stages from what the pool holds, and
305 // falls back to the heap rather than allocate.
306 let update = stream.capturing().page_update();
307 let handle = stream
308 .host_memory()
309 .reserve(size as u64, update, failures)
310 .ok()?;
311
312 let binding = MemoryHandle::binding(handle);
313 stream.capturing().prime(&binding);
314 let resource = stream
315 .host_memory()
316 .get_resource(binding.clone(), None, None)
317 .ok()?;
318
319 // SAFETY: the binding has initialized memory for at least `size` bytes,
320 // and `resource` is what the manager just resolved it to.
321 Some(unsafe { D::pinned_bytes(binding, resource, size) })
322 }
323
324 /// Copy each descriptor's device memory back to the host, resolving once
325 /// the copies have landed.
326 ///
327 /// The copies are enqueued before the future is returned; awaiting it
328 /// waits on the fence that follows them.
329 ///
330 /// # Errors
331 ///
332 /// [`IoError::UnsupportedStrides`] for a layout the driver cannot copy,
333 /// and whatever the fence reports when the stream itself failed.
334 pub fn read_async(
335 &mut self,
336 descriptors: Vec<CopyDescriptor>,
337 ) -> impl Future<Output = Result<Vec<Bytes>, ServerError>> + Send + use<D> {
338 let held = descriptors
339 .iter()
340 .map(|descriptor| descriptor.handle.clone())
341 .collect::<Vec<_>>();
342 let result = self.copies_to_bytes(descriptors);
343 let fence = D::Stream::fence(self.streams.current().signal());
344
345 async move {
346 if let Err(err) = fence.wait() {
347 // Both the device sources and host destinations may still be in use.
348 release_or_leak((held, result), &err);
349 return Err(err);
350 }
351 // The bindings kept the source allocations alive across the copies;
352 // the fence above is what says they are done being read.
353 core::mem::drop(held);
354
355 result.map_err(Into::into)
356 }
357 }
358
359 /// Copy each descriptor's device memory into a fresh host buffer.
360 fn copies_to_bytes(&mut self, descriptors: Vec<CopyDescriptor>) -> Result<Vec<Bytes>, IoError> {
361 let mut result = Vec::with_capacity(descriptors.len());
362
363 for descriptor in descriptors {
364 match self.copy_to_bytes(descriptor, None) {
365 Ok(bytes) => result.push(bytes),
366 Err(err) => {
367 // The buffers collected so far are the destinations of
368 // copies already enqueued: dropping them hands their
369 // pinned slices back to a pool whose reuse is gated on
370 // the refcount alone, while the device is still writing
371 // them. The fence `read_async` records to cover exactly
372 // this does not exist yet on the error path, so record
373 // one here and wait it out before the partial set drops.
374 if !result.is_empty()
375 && !D::Stream::fence(self.streams.current().signal()).sync()
376 {
377 core::mem::forget(result);
378 }
379 return Err(err);
380 }
381 }
382 }
383
384 Ok(result)
385 }
386
387 /// Copy one descriptor's device memory into a fresh host buffer.
388 fn copy_to_bytes(
389 &mut self,
390 descriptor: CopyDescriptor,
391 stream_id: Option<StreamId>,
392 ) -> Result<Bytes, IoError> {
393 let num_bytes = descriptor.shape.iter().product::<usize>() * descriptor.elem_size;
394 let mut bytes = self.reserve_cpu(num_bytes, stream_id);
395 self.write_to_cpu(descriptor, &mut bytes, stream_id)?;
396
397 Ok(bytes)
398 }
399
400 /// Enqueue a copy of `descriptor`'s device memory into `bytes`.
401 ///
402 /// # Errors
403 ///
404 /// [`IoError::UnsupportedStrides`] for a layout that is not pitched
405 /// row-major, [`IoError::StorageHandleNotFound`] for a binding that names no live
406 /// allocation, and the driver's refusal to copy.
407 pub fn write_to_cpu(
408 &mut self,
409 descriptor: CopyDescriptor,
410 bytes: &mut Bytes,
411 stream_id: Option<StreamId>,
412 ) -> Result<(), IoError> {
413 let CopyDescriptor {
414 handle: binding,
415 shape,
416 strides,
417 elem_size,
418 } = descriptor;
419 // Nothing to copy for an empty tensor, and `bytes` has no real backing
420 // for the driver to write into — a dangling zero-size buffer.
421 // Its strides may contain zeros, so skip copy-layout validation too.
422 if bytes.is_empty() {
423 return Ok(());
424 }
425
426 let layout = CopyLayout::of(&shape, &strides, elem_size)?;
427 let resource = self.resource(binding)?;
428 let stream = match stream_id {
429 Some(id) => self.streams.get(&id),
430 None => self.streams.current(),
431 };
432
433 // SAFETY: `resource` is a live device allocation the manager just
434 // resolved, `bytes` was sized for this copy, and the caller awaits the
435 // fence `read_async` records before reading it back.
436 unsafe { D::copy_to_host(&resource, &layout, bytes, stream) }
437 }
438
439 /// Enqueue a copy of `data` into the device memory `descriptor` names.
440 ///
441 /// # Errors
442 ///
443 /// [`IoError::UnsupportedStrides`] for a layout that is not pitched
444 /// row-major, [`IoError::StorageHandleNotFound`] for a binding that names no live
445 /// allocation, and the driver's refusal to copy.
446 pub fn write_to_gpu(&mut self, descriptor: CopyDescriptor, data: Bytes) -> Result<(), IoError> {
447 let CopyDescriptor {
448 handle: binding,
449 shape,
450 strides,
451 elem_size,
452 } = descriptor;
453 let size = data.len();
454
455 // An empty tensor (a zero dim in its shape) has nothing to copy. Bail
456 // before validating its potentially zero strides or staging: the zero-size
457 // staging buffer has no real backing (a dangling pointer), and a 2D copy
458 // would still transfer `width_bytes` from it when only the leading dims are zero.
459 if size == 0 {
460 return Ok(());
461 }
462
463 let layout = CopyLayout::of(&shape, &strides, elem_size)?;
464 let resource = self.resource(binding)?;
465 let staging = Staging::of(size, data.property());
466
467 let data = match staging.through_pinned {
468 true => {
469 // Pinned staging is a DMA optimization, not a requirement, so
470 // an exhausted pinned pool falls back to a plain heap buffer
471 // rather than failing the write — the same answer `reserve_cpu`
472 // gives for the same condition. File-backed data still lands in
473 // real memory before the driver reads it asynchronously, which
474 // is the half of the staging that is mandatory.
475 let mut buffer = self
476 .reserve_pinned(size, None)
477 .unwrap_or_else(|| Bytes::from_bytes_vec(vec![0; size]));
478 data.copy_into(&mut buffer);
479 buffer
480 }
481 false => data,
482 };
483
484 let current = self.streams.current();
485
486 // SAFETY: `resource` is a live device allocation, `data` is a valid
487 // host buffer, and either the drop queue or the capture window below
488 // keeps it alive for as long as the device reads it.
489 unsafe { D::copy_to_device(&resource, &layout, &data, current)? };
490
491 if current.capturing().is_recording() {
492 // A copy recorded into a graph is not executed now but re-read on
493 // every replay: the node keeps the raw host pointer, so the bytes
494 // ride the window onto the graph rather than the drop queue —
495 // which frees them when the window closes, exactly when the graph
496 // starts needing them.
497 current.capturing().retain_host(data);
498 } else {
499 current.drop_queue().push(data);
500 if staging.flush_after || current.drop_queue().should_flush() {
501 let signal = current.signal();
502 current.drop_queue().flush(|| D::Stream::fence(signal));
503 }
504 }
505
506 Ok(())
507 }
508
509 /// Allocate device memory for `data` and enqueue the copy into it.
510 ///
511 /// # Errors
512 ///
513 /// Whatever the allocation or the copy reports.
514 pub fn create_with_data(&mut self, data: &[u8]) -> Result<Handle, IoError> {
515 let mut staging =
516 self.reserve_pinned(data.len(), None)
517 .ok_or_else(|| IoError::Unknown {
518 backtrace: BackTrace::capture(),
519 description: "Unable to reserve pinned memory".into(),
520 })?;
521
522 staging.copy_from_slice(data);
523
524 let handle = self.empty(staging.len() as u64)?;
525
526 self.write_to_gpu(
527 CopyDescriptor {
528 handle: handle.clone().binding(),
529 shape: [data.len()].into(),
530 strides: [1].into(),
531 elem_size: 1,
532 },
533 staging,
534 )?;
535
536 Ok(handle)
537 }
538
539 /// Wait for everything already enqueued on the current stream to finish.
540 ///
541 /// # Errors
542 ///
543 /// The fault the barrier reveals, when the stream itself failed.
544 pub fn sync(&mut self) -> DynFut<Result<(), ServerError>> {
545 let fence = D::Stream::fence(self.streams.current().signal());
546
547 Box::pin(async move { fence.wait() })
548 }
549
550 /// Enqueue an already-compiled kernel on the current stream.
551 ///
552 /// # Errors
553 ///
554 /// The driver's refusal to enqueue the launch, returned whether or not a
555 /// profile is open. An open profile is not a reason to hold the failure
556 /// here: the caller's write scope is what claims the buffers the launch
557 /// never wrote, and the caller invalidates every open profile on the same
558 /// path, so keeping it would lose the claim and duplicate the report.
559 pub fn kernel(
560 &mut self,
561 kernel: KernelId,
562 count: (u32, u32, u32),
563 args: &mut D::LaunchArgs,
564 ) -> Result<(), LaunchError> {
565 let stream = self.streams.current();
566 let result = D::launch(self.ctx, stream, kernel, count, args);
567
568 // A fenced flush during capture would abort it; defer until the capture
569 // ends, when the deferred staging buffers are reclaimed.
570 if !stream.capturing().is_recording() && stream.drop_queue().should_flush() {
571 let signal = stream.signal();
572 stream.drop_queue().flush(|| D::Stream::fence(signal));
573 }
574
575 result
576 }
577}
578
579impl<D: Driver> RelocatingStreams for Command<'_, D> {
580 fn recording(&mut self) -> bool {
581 self.streams.current().capturing().device().any_recording()
582 }
583
584 fn has_outdated(&mut self) -> bool {
585 self.streams.current().device_memory().has_outdated()
586 }
587
588 fn relocation_need(&mut self) -> RelocationNeed {
589 self.streams.current().device_memory().relocation_need()
590 }
591
592 fn bytes_allocated(&mut self) -> u64 {
593 self.streams
594 .all()
595 .map(|stream| stream.device_memory().bytes_allocated())
596 .sum()
597 }
598
599 /// Nothing here: `StreamCopies` waits on every stream before its first
600 /// copy, so a relocation with nothing to move waits on none.
601 fn finish(&mut self) {}
602
603 fn cleanup_memory(&mut self) {
604 let (stream, failures) = self.streams.current_and_failures();
605 stream.device_memory().cleanup(Cleanup::Explicit, failures);
606 }
607
608 fn relocate_memory(&mut self, reason: RelocationReason) {
609 let current = self.streams.current;
610 self.relocate_stream(current, reason);
611 }
612
613 fn device_has_outdated(&mut self) -> bool {
614 self.streams
615 .all()
616 .any(|stream| stream.device_memory().has_outdated())
617 }
618
619 fn relocate_device_memory(&mut self, reason: RelocationReason) {
620 for stream_id in self.streams.stream_ids() {
621 if self.streams.get(&stream_id).device_memory().has_outdated() {
622 self.relocate_stream(stream_id, reason);
623 }
624 }
625 }
626}
627
628impl<D: Driver> Command<'_, D> {
629 /// Move what `stream_id`'s memory holds on outdated pages, with the copies
630 /// queued on that stream.
631 fn relocate_stream(&mut self, stream_id: StreamId, reason: RelocationReason) {
632 // Rare enough to gather the signals as it goes: it waits on the whole
633 // device anyway.
634 let signals: Vec<_> = self.streams.all().map(|stream| stream.signal()).collect();
635 let queue = self.streams.get(&stream_id).signal();
636 let mut copier = StreamCopies::<D>::new(signals, queue, self.ctx);
637 let (stream, failures) = self.streams.get_and_failures(&stream_id);
638 stream
639 .device_memory()
640 .relocate(&mut copier, reason, failures);
641 }
642}