cubecl_runtime/client.rs
1use crate::{
2 config::memory::MemoryPoolsConfig,
3 config::{TypeNameFormatLevel, type_name_format},
4 id::{GraphId, KernelId},
5 kernel::CubeKernel,
6 logging::ProfileLevel,
7 memory_management::{
8 InstallMemoryPoolsError, MemoryAllocationMode, MemoryConfiguration, MemoryReport,
9 MemoryUsage,
10 },
11 server::{
12 BufferBinding, Collective, CommunicationId, CopyDescriptor, CubeCount, Handle,
13 KernelArguments, KernelResource, MemoryLayout, MemoryLayoutDescriptor,
14 MemoryLayoutStrategy, ProfileError, ProfilingToken, ReduceOperation, Server, ServerError,
15 ServerStorage, ServerUtilities,
16 },
17 storage::{ComputeStorage, ManagedResource},
18 throughput::{
19 ThroughputBenchmarker, ThroughputCache, ThroughputError, ThroughputKey, ThroughputValue,
20 },
21};
22use alloc::{boxed::Box, format, string::String, sync::Arc, vec, vec::Vec};
23use core::any::{Any, TypeId};
24
25#[cfg(not(target_family = "wasm"))]
26mod lazy;
27use cubecl_common::{
28 bytes::{AllocationProperty, Bytes},
29 device::{DeviceId, ServiceId},
30 device_handle::{CallResultExt, DeviceHandle},
31 profile::ProfileDuration,
32};
33use cubecl_environment::backtrace::BackTrace;
34use cubecl_environment::future::DynFut;
35use cubecl_ir::{DeviceProperties, ElemType, TargetProperties, VectorSize, features::Features};
36use cubecl_zspace::Shape;
37
38#[allow(unused)]
39use cubecl_common::profile::TimingMethod;
40use cubecl_environment::stream::StreamId;
41
42/// The `Client` is the entry point to require tasks from the `Server`.
43/// It should be obtained for a specific device via the Compute struct.
44pub struct Client {
45 device: DeviceHandle<dyn Server>,
46 utilities: Arc<ServerUtilities>,
47 stream_id: Option<StreamId>,
48}
49
50/// A captured graph produced by [`Client::stop_capture`]: a recorded
51/// launch sequence that [`replay`](Graph::replay) re-runs against its original
52/// buffers, skipping the launch path it was recorded from. Cheap to clone
53/// (shares one backend graph).
54///
55/// The graph itself lives in the backend server, referenced here only by
56/// [`GraphId`]; this handle holds a reference-counted owner that releases the
57/// backend graph once the last clone drops. The graph replays against the exact
58/// device buffers used during capture. The caller keeps those input/output
59/// [`Handle`]s alive and, each iteration, writes fresh inputs into the input
60/// handles (same device pointers) and reads the output handles after replaying —
61/// see [`Client::stop_capture`].
62///
63/// **Stream ordering.** [`replay`](Graph::replay) always dispatches on the
64/// stream the graph was captured on, but input writes and output reads go on the
65/// *writing client's* current stream. They are ordered against the replay only
66/// when they land on that same stream, so keep the client pinned to the capture
67/// stream (via [`set_stream`](Client::set_stream)) — or issue all writes,
68/// replays, and reads from the same unpinned client — for the whole decode loop.
69/// Refreshing inputs from a client on a different stream races the replay and
70/// silently feeds it stale data.
71pub struct Graph {
72 inner: Arc<GraphHandle>,
73}
74
75impl core::fmt::Debug for Graph {
76 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
77 f.debug_struct("Graph")
78 .field("id", &self.inner.id)
79 .field("stream_id", &self.inner.stream_id)
80 .finish()
81 }
82}
83
84/// Reference-counted owner of a backend graph. Its [`Drop`] ships the release to
85/// the server actor, so the last [`Graph`] clone frees the backend graph on the
86/// thread that owns it.
87struct GraphHandle {
88 id: GraphId,
89 device: DeviceHandle<dyn Server>,
90 stream_id: StreamId,
91}
92
93impl Graph {
94 /// Replay the captured launch sequence — every recorded kernel re-run
95 /// against the buffers it was captured with, on the stream it was captured
96 /// on. Self-contained (the handle owns its device handle); no client
97 /// needed.
98 ///
99 /// How much of the launch path this skips depends on the backend: a
100 /// hardware graph (CUDA, HIP) replays as one dispatch, while a software
101 /// graph (wgpu) re-encodes the recorded dispatches from prebuilt state.
102 /// Either way pipeline lookup, binding resolution and metadata upload
103 /// happened once, at capture.
104 ///
105 /// Blocking only on the enqueue: [`replay`](Self::replay) waits for the
106 /// device thread to accept the dispatch and hands back what that enqueue
107 /// said — an unknown or destroyed graph, a refusal — then returns without
108 /// waiting for the device. A failure also leaves the graph's write set
109 /// carrying it, so a read of those buffers keeps failing until a replay
110 /// lands.
111 ///
112 /// The wait costs end-to-end throughput nothing: the device-thread work
113 /// happens either way, and blocking here only stops deferring it to the
114 /// next sync. What it does move is the caller-visible latency of this
115 /// call, from the cost of posting to a channel to the real cost of
116 /// enqueuing the pass — so a benchmark reading this column is reading
117 /// latency, not throughput.
118 ///
119 /// # Safety
120 ///
121 /// The dispatch re-runs the recorded kernels against the raw device pointers
122 /// captured with them; nothing validates those buffers still exist or are
123 /// unshared. The caller must guarantee, until the replay's work completes on
124 /// the stream:
125 ///
126 /// - **Liveness** — every [`Handle`] the captured kernels read or wrote is
127 /// still allocated. Freeing one returns its memory to the pool, and a
128 /// later replay reads or corrupts whatever the allocator has since placed
129 /// there.
130 /// - **No concurrent use** — no other stream or thread touches buffers the
131 /// graph reads or writes while the replay executes; the replay is ordered
132 /// only against work on its capture stream.
133 /// - **Same-stream refreshes** — input writes and output reads are issued on
134 /// the capture stream (keep the client pinned to it via
135 /// [`set_stream`](Client::set_stream), or do everything from the
136 /// one client), so they order against the replay instead of racing it.
137 pub unsafe fn replay(&self) -> Result<(), ServerError> {
138 let id = self.inner.id;
139 let stream_id = self.inner.stream_id;
140 self.inner
141 .device
142 .submit_blocking(move |server| server.replay(id, stream_id))
143 .unwrap_or_resume()
144 }
145}
146
147impl Clone for Graph {
148 fn clone(&self) -> Self {
149 Self {
150 inner: self.inner.clone(),
151 }
152 }
153}
154
155impl Drop for GraphHandle {
156 fn drop(&mut self) {
157 let id = self.id;
158 let stream_id = self.stream_id;
159 // Destroying the raw executable must happen on the server actor (the
160 // only thread allowed to touch it) and only once in-flight replays have
161 // completed — `replay` returns at enqueue time, not completion. Ship the
162 // release to the actor; the backend syncs the stream before it destroys.
163 self.device
164 .submit(move |server| server.graph_destroy(id, stream_id));
165 }
166}
167
168/// A profiling window opened by [`Client::profile_start`], closed by
169/// [`Client::profile_end`] or dropped by [`Client::profile_abandon`].
170///
171/// It remembers the stream it was opened on, so closing it from another
172/// thread still closes it on that stream. It is a plain value with no
173/// [`Drop`]: a window that is neither ended nor abandoned stays open on the
174/// server.
175#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
176pub struct ProfileWindow {
177 /// The stream the window was opened on.
178 pub stream_id: StreamId,
179 /// The server's token for the window.
180 pub token: ProfilingToken,
181}
182
183/// The state a `DeviceHandle` reaches, seen as the server it is. A client
184/// keeps this cast, not the server type, so every operation reads the same
185/// whatever backend is underneath.
186fn as_server<S: Server>(state: &mut dyn Any) -> &mut dyn Server {
187 state
188 .downcast_mut::<S>()
189 .expect("State type mismatch in the device registry")
190}
191
192impl Clone for Client {
193 fn clone(&self) -> Self {
194 Self {
195 device: self.device.clone(),
196 utilities: self.utilities.clone(),
197 stream_id: self.stream_id,
198 }
199 }
200}
201
202impl Client {
203 /// The runtime name on this device, as logs and cache keys show it.
204 pub fn name(&self) -> &'static str {
205 self.utilities.name
206 }
207
208 /// Create a new client with a new server.
209 pub fn init<S: ServerStorage>(device_id: DeviceId, server: S) -> Self {
210 let utilities = Server::utilities(&server);
211 let context = DeviceHandle::<S>::insert(device_id, server)
212 .expect("Can't create a new client on an already registered server")
213 .seen_as(as_server::<S>);
214
215 Self {
216 device: context,
217 utilities,
218 stream_id: None,
219 }
220 }
221
222 /// Load the client for the given device, starting a server of type `S`
223 /// there if none runs yet.
224 pub fn load<S: ServerStorage>(device_id: DeviceId) -> Self {
225 let context = DeviceHandle::<S>::new(device_id).seen_as(as_server::<S>);
226
227 // This is safe because we now know the return type of [`DeviceHandle::utilities()`].
228 let utilities = context
229 .utilities()
230 .downcast::<ServerUtilities>()
231 .expect("Can downcast to `ServerUtilities`");
232
233 Self {
234 device: context,
235 utilities,
236 stream_id: None,
237 }
238 }
239
240 fn stream_id(&self) -> StreamId {
241 match self.stream_id {
242 Some(val) => val,
243 None => StreamId::current(),
244 }
245 }
246
247 /// The service this client reaches: what its handles are stamped with.
248 pub fn service_id(&self) -> ServiceId {
249 self.device.service_id()
250 }
251
252 /// Whether the server behind this client is an `S`. The client is erased
253 /// over its server type, so a caller naming one has to be checked here,
254 /// before a downcast on the device thread turns the mismatch into a panic.
255 fn is_service<S: 'static>(&self) -> bool {
256 TypeId::of::<S>() == self.service_id().service
257 }
258
259 /// Whether `binding` addresses this client's device. Memory coordinates
260 /// mean nothing on another device, so a foreign binding is refused here,
261 /// before anything is submitted, rather than read there.
262 fn local(&self, binding: &BufferBinding) -> Result<(), ServerError> {
263 let client = self.service_id();
264 if binding.service == client {
265 return Ok(());
266 }
267 Err(ServerError::ForeignHandle {
268 handle: format!("{}", binding.service),
269 client: format!("{client}"),
270 backtrace: BackTrace::capture(),
271 })
272 }
273
274 /// [`local`](Self::local) for a call that has no error to return: a
275 /// foreign handle is a bug in the caller, and the alternative to stopping
276 /// here is reading another device's memory.
277 #[track_caller]
278 fn expect_local(&self, binding: &BufferBinding) {
279 if let Err(err) = self.local(binding) {
280 panic!("{err}");
281 }
282 }
283
284 /// Set the stream in which the current client is operating on.
285 ///
286 /// # Safety
287 ///
288 /// This is highly unsafe and should probably only be used by the CubeCL/Burn projects for now.
289 pub unsafe fn set_stream(&mut self, stream_id: StreamId) {
290 self.stream_id = Some(stream_id);
291 }
292
293 fn do_read(&self, descriptors: Vec<CopyDescriptor>) -> DynFut<Result<Vec<Bytes>, ServerError>> {
294 if let Some(err) = descriptors
295 .iter()
296 .find_map(|descriptor| self.local(&descriptor.handle).err())
297 {
298 return Box::pin(core::future::ready(Err(err)));
299 }
300 let stream_id = self.stream_id();
301 self.device
302 .submit_blocking(move |server| server.read(descriptors, stream_id))
303 .unwrap_or_resume()
304 }
305
306 /// Given bindings, returns owned resources as bytes.
307 pub fn read_async(
308 &self,
309 handles: Vec<Handle>,
310 ) -> impl Future<Output = Result<Vec<Bytes>, ServerError>> + Send {
311 let shapes = handles
312 .iter()
313 .map(|it| [it.size_in_used() as usize].into())
314 .collect::<Vec<Shape>>();
315 let descriptors = handles
316 .into_iter()
317 .zip(shapes)
318 .map(|(handle, shape)| CopyDescriptor::new(handle.binding(), shape, [1].into(), 1))
319 .collect();
320
321 self.do_read(descriptors)
322 }
323
324 /// Given bindings, returns owned resources as bytes.
325 ///
326 /// # Remarks
327 ///
328 /// Panics if the read operation fails.
329 pub fn read(&self, handles: Vec<Handle>) -> Vec<Bytes> {
330 cubecl_environment::future::reader::read_sync(self.read_async(handles)).expect("TODO")
331 }
332
333 /// Given a binding, returns owned resource as bytes.
334 pub fn read_one(&self, handle: Handle) -> Result<Bytes, ServerError> {
335 Ok(cubecl_environment::future::reader::read_sync(self.read_async(vec![handle]))?.remove(0))
336 }
337
338 /// Given a binding, returns owned resource as bytes.
339 ///
340 /// # Remarks
341 ///
342 /// Panics if the read operation fails. Useful for tests.
343 pub fn read_one_unchecked(&self, handle: Handle) -> Bytes {
344 cubecl_environment::future::reader::read_sync(self.read_async(vec![handle]))
345 .unwrap()
346 .remove(0)
347 }
348
349 /// Given bindings, returns owned resources as bytes.
350 pub fn read_tensor_async(
351 &self,
352 descriptors: Vec<CopyDescriptor>,
353 ) -> impl Future<Output = Result<Vec<Bytes>, ServerError>> + Send {
354 self.do_read(descriptors)
355 }
356
357 /// Given bindings, returns owned resources as bytes.
358 ///
359 /// # Remarks
360 ///
361 /// Panics if the read operation fails.
362 ///
363 /// The tensor must be in the same layout as created by the runtime, or more strict.
364 /// Contiguous tensors are always fine, strided tensors are only ok if the stride is similar to
365 /// the one created by the runtime (i.e. padded on only the last dimension). A way to check
366 /// stride compatibility on the runtime will be added in the future.
367 ///
368 /// Also see [`Client::create_tensor`].
369 pub fn read_tensor(&self, descriptors: Vec<CopyDescriptor>) -> Vec<Bytes> {
370 cubecl_environment::future::reader::read_sync(self.read_tensor_async(descriptors))
371 .expect("TODO")
372 }
373
374 /// Given a binding, returns owned resource as bytes.
375 /// See [`Client::read_tensor`]
376 pub fn read_one_tensor_async(
377 &self,
378 descriptor: CopyDescriptor,
379 ) -> impl Future<Output = Result<Bytes, ServerError>> + Send {
380 let fut = self.read_tensor_async(vec![descriptor]);
381
382 async { Ok(fut.await?.remove(0)) }
383 }
384
385 /// Given a binding, returns owned resource as bytes.
386 ///
387 /// # Remarks
388 ///
389 /// Panics if the read operation fails.
390 /// See [`Client::read_tensor`]
391 pub fn read_one_unchecked_tensor(&self, descriptor: CopyDescriptor) -> Bytes {
392 self.read_tensor(vec![descriptor]).remove(0)
393 }
394
395 /// Reads the device resource described by `descriptor` lazily.
396 ///
397 /// The returned [`Bytes`] only performs the device-to-host copy on first access (e.g. during
398 /// serialization), keeping the source allocation alive until then. This lets a large number of
399 /// device tensors be serialized without materializing them all in host memory at once: drain
400 /// the [`Bytes`] sequentially rather than holding them all alive.
401 ///
402 /// The data reflects the device state at first access, so the buffer must not be mutated
403 /// between this call and the first read.
404 #[cfg(not(target_family = "wasm"))]
405 pub fn read_lazy(&self, descriptor: CopyDescriptor) -> Bytes {
406 self.expect_local(&descriptor.handle);
407 let len = descriptor.shape.iter().product::<usize>() * descriptor.elem_size;
408 let controller = lazy::LazyDeviceController::new(self.clone(), Arc::new(descriptor));
409 // SAFETY: the controller materializes exactly `len` bytes on first access.
410 unsafe { Bytes::from_controller(alloc::boxed::Box::new(controller), len) }
411 }
412
413 /// Reads the device resource described by `descriptor` lazily, async variant.
414 ///
415 /// On native targets the returned future is immediately ready and yields a lazy [`Bytes`]
416 /// whose device-to-host copy is deferred to first access (see [`read_lazy`](Self::read_lazy)).
417 #[cfg(not(target_family = "wasm"))]
418 pub fn read_lazy_async(
419 &self,
420 descriptor: CopyDescriptor,
421 ) -> impl Future<Output = Result<Bytes, ServerError>> + Send {
422 if let Err(err) = self.local(&descriptor.handle) {
423 return core::future::ready(Err(err));
424 }
425 let len = descriptor.shape.iter().product::<usize>() * descriptor.elem_size;
426 let controller = lazy::LazyDeviceController::new(self.clone(), Arc::new(descriptor));
427 // SAFETY: the controller materializes exactly `len` bytes on first access.
428 let bytes = unsafe { Bytes::from_controller(alloc::boxed::Box::new(controller), len) };
429 core::future::ready(Ok(bytes))
430 }
431
432 /// Reads the device resource described by `descriptor` lazily, async variant.
433 ///
434 /// On `wasm` the deferred copy cannot run inside the synchronous access path, so awaiting
435 /// performs the read eagerly and yields a materialized [`Bytes`]. Awaiting one tensor at a
436 /// time still bounds peak host memory, which is the point of the lazy API.
437 #[cfg(target_family = "wasm")]
438 pub fn read_lazy_async(
439 &self,
440 descriptor: CopyDescriptor,
441 ) -> impl Future<Output = Result<Bytes, ServerError>> + Send {
442 self.read_one_tensor_async(descriptor)
443 }
444
445 /// Given a resource handle, returns the storage resource.
446 pub fn get_resource<S: ServerStorage>(
447 &self,
448 handle: Handle,
449 ) -> Result<ManagedResource<<S::Storage as ComputeStorage>::Resource>, ServerError> {
450 let stream_id = self.stream_id();
451 let binding = handle.binding();
452 self.local(&binding)?;
453 if !self.is_service::<S>() {
454 return Err(ServerError::ServiceMismatch {
455 client: format!("{}", self.service_id()),
456 requested: String::from(core::any::type_name::<S>()),
457 backtrace: BackTrace::capture(),
458 });
459 }
460
461 self.device
462 .submit_blocking(move |server| {
463 let server = (server as &mut dyn Any)
464 .downcast_mut::<S>()
465 .expect("is_service passed, so this is the server's type");
466 server.get_resource(binding, stream_id)
467 })
468 .unwrap_or_resume()
469 }
470
471 fn do_create_from_slices(
472 &self,
473 descriptors: Vec<MemoryLayoutDescriptor>,
474 slices: Vec<Vec<u8>>,
475 ) -> Vec<MemoryLayout> {
476 let stream_id = self.stream_id();
477 let (handle_base, layouts) =
478 self.utilities
479 .layout_policy
480 .apply(self.service_id(), stream_id, &descriptors);
481
482 let descriptors = descriptors
483 .into_iter()
484 .zip(layouts.iter())
485 .zip(slices)
486 .map(|((desc, alloc), data)| {
487 (
488 CopyDescriptor::new(
489 alloc.memory.clone().binding(),
490 desc.shape,
491 alloc.strides.clone(),
492 desc.elem_size,
493 ),
494 Bytes::from_bytes_vec(data.to_vec()),
495 )
496 })
497 .collect::<Vec<_>>();
498
499 let (size, memory) = (handle_base.size(), handle_base.memory);
500 self.device.submit(move |server| {
501 server.initialize_memory(memory, size, stream_id);
502 server.write(descriptors, stream_id);
503 });
504
505 layouts
506 }
507
508 fn do_create(
509 &self,
510 descriptors: Vec<MemoryLayoutDescriptor>,
511 data: Vec<Bytes>,
512 ) -> Vec<MemoryLayout> {
513 let stream_id = self.stream_id();
514 let (handle_base, layouts) =
515 self.utilities
516 .layout_policy
517 .apply(self.service_id(), stream_id, &descriptors);
518
519 let descriptors = descriptors
520 .into_iter()
521 .zip(layouts.iter())
522 .zip(data)
523 .map(|((desc, layout), data)| {
524 (
525 CopyDescriptor::new(
526 layout.memory.clone().binding(),
527 desc.shape,
528 layout.strides.clone(),
529 desc.elem_size,
530 ),
531 data,
532 )
533 })
534 .collect::<Vec<_>>();
535
536 let (size, memory) = (handle_base.size(), handle_base.memory);
537 self.device.submit(move |server| {
538 server.initialize_memory(memory, size, stream_id);
539 server.write(descriptors, stream_id);
540 });
541
542 layouts
543 }
544
545 /// Returns a resource handle containing the given data.
546 ///
547 /// # Notes
548 ///
549 /// Prefer using the more efficient [`Self::create`] function.
550 pub fn create_from_slice(&self, slice: &[u8]) -> Handle {
551 let shape: Shape = [slice.len()].into();
552
553 self.do_create_from_slices(
554 vec![MemoryLayoutDescriptor::new(
555 MemoryLayoutStrategy::Contiguous,
556 shape,
557 1,
558 )],
559 vec![slice.to_vec()],
560 )
561 .remove(0)
562 .memory
563 }
564
565 /// Run `task` with this device to itself, so nothing else is scheduled
566 /// against it for the duration.
567 ///
568 /// # Errors
569 ///
570 /// The device could not be taken exclusively — another holder has it, or
571 /// its runner is gone. Nothing ran, so the caller may retry.
572 pub fn exclusive<'a, Re: Send + 'static, F: FnOnce() -> Re + Send + 'a>(
573 &'a self,
574 task: F,
575 ) -> Result<Re, ServerError> {
576 // We then launch the task.
577 self.device
578 .exclusive(task)
579 .map_err(|err| ServerError::Generic {
580 reason: format!("{err:?}"),
581 backtrace: BackTrace::capture(),
582 })
583 }
584
585 /// Run `task` with every allocation it makes routed to the persistent
586 /// pool, then restore the previous mode.
587 ///
588 /// Persistent slices are exact-fit and are not reclaimed by the ordinary
589 /// sweep, which is what weights want: allocated once, alive for the
590 /// process, and stable enough for a graph capture to record against.
591 pub fn memory_persistent_allocation<
592 'a,
593 Re: Send,
594 Input: Send,
595 F: FnOnce(Input) -> Re + Send + 'a,
596 >(
597 &'a self,
598 input: Input,
599 task: F,
600 ) -> Re {
601 let stream_id = StreamId::current();
602
603 self.device.submit(move |server| {
604 server.allocation_mode(MemoryAllocationMode::Persistent, stream_id);
605 });
606
607 // All tasks created on the same stream will have persistent memory.
608 let output = task(input);
609
610 self.device.submit(move |server| {
611 server.allocation_mode(MemoryAllocationMode::Auto, stream_id);
612 });
613
614 output
615 }
616
617 /// Write `data` into an existing allocation, in place (same device pointer).
618 ///
619 /// This is how a captured [`Graph`]'s inputs are refreshed between replays:
620 /// the graph records raw device pointers, so new input bytes must land in
621 /// the very buffer the capture read from. Issue it from the capture stream
622 /// (see the stream-ordering notes on [`Graph`]) so the write orders against
623 /// the replays instead of racing them.
624 ///
625 /// Non-blocking: the write is enqueued on this client's current stream.
626 pub fn write(&self, handle: &Handle, data: Bytes) {
627 let stream_id = self.stream_id();
628 let descriptor =
629 CopyDescriptor::new(handle.clone().binding(), [data.len()].into(), [1].into(), 1);
630 self.expect_local(&descriptor.handle);
631 self.device.submit(move |server| {
632 server.write(vec![(descriptor, data)], stream_id);
633 });
634 }
635
636 /// Returns a resource handle containing the given [Bytes].
637 pub fn create(&self, data: Bytes) -> Handle {
638 let shape = [data.len()].into();
639
640 self.do_create(
641 vec![MemoryLayoutDescriptor::new(
642 MemoryLayoutStrategy::Contiguous,
643 shape,
644 1,
645 )],
646 vec![data],
647 )
648 .remove(0)
649 .memory
650 }
651
652 /// Given a resource and shape, stores it and returns the tensor handle and strides.
653 /// This may or may not return contiguous strides. The layout is up to the runtime, and care
654 /// should be taken when indexing.
655 ///
656 /// Currently the tensor may either be contiguous (most runtimes), or "pitched", to use the CUDA
657 /// terminology. This means the last (contiguous) dimension is padded to fit a certain alignment,
658 /// and the strides are adjusted accordingly. This can make memory accesses significantly faster
659 /// since all rows are aligned to at least 16 bytes (the maximum load width), meaning the GPU
660 /// can load as much data as possible in a single instruction. It may be aligned even more to
661 /// also take cache lines into account.
662 ///
663 /// However, the stride must be taken into account when indexing and reading the tensor
664 /// (also see [`Client::read_tensor`]).
665 ///
666 /// # Notes
667 ///
668 /// Prefer using [`Self::create_tensor`] for better performance.
669 pub fn create_tensor_from_slice(
670 &self,
671 slice: &[u8],
672 shape: Shape,
673 elem_size: usize,
674 ) -> MemoryLayout {
675 self.do_create_from_slices(
676 vec![MemoryLayoutDescriptor::new(
677 MemoryLayoutStrategy::Optimized,
678 shape,
679 elem_size,
680 )],
681 vec![slice.to_vec()],
682 )
683 .remove(0)
684 }
685
686 /// Given a resource and shape, stores it and returns the tensor handle and strides.
687 /// This may or may not return contiguous strides. The layout is up to the runtime, and care
688 /// should be taken when indexing.
689 ///
690 /// Currently the tensor may either be contiguous (most runtimes), or "pitched", to use the CUDA
691 /// terminology. This means the last (contiguous) dimension is padded to fit a certain alignment,
692 /// and the strides are adjusted accordingly. This can make memory accesses significantly faster
693 /// since all rows are aligned to at least 16 bytes (the maximum load width), meaning the GPU
694 /// can load as much data as possible in a single instruction. It may be aligned even more to
695 /// also take cache lines into account.
696 ///
697 /// However, the stride must be taken into account when indexing and reading the tensor
698 /// (also see [`Client::read_tensor`]).
699 pub fn create_tensor(&self, bytes: Bytes, shape: Shape, elem_size: usize) -> MemoryLayout {
700 self.do_create(
701 vec![MemoryLayoutDescriptor::new(
702 MemoryLayoutStrategy::Optimized,
703 shape,
704 elem_size,
705 )],
706 vec![bytes],
707 )
708 .remove(0)
709 }
710
711 /// Reserves all `shapes` in a single storage buffer, copies the corresponding `data` into each
712 /// handle, and returns the handles for them.
713 /// See [`Client::create_tensor`]
714 ///
715 /// # Notes
716 ///
717 /// Prefer using [`Self::create_tensors`] for better performance.
718 pub fn create_tensors_from_slices(
719 &self,
720 descriptors: Vec<(MemoryLayoutDescriptor, &[u8])>,
721 ) -> Vec<MemoryLayout> {
722 let mut data = Vec::with_capacity(descriptors.len());
723 let mut descriptors_ = Vec::with_capacity(descriptors.len());
724 for (a, b) in descriptors {
725 data.push(b.to_vec());
726 descriptors_.push(a);
727 }
728
729 self.do_create_from_slices(descriptors_, data)
730 }
731
732 /// Reserves all `shapes` in a single storage buffer, copies the corresponding `data` into each
733 /// handle, and returns the handles for them.
734 /// See [`Client::create_tensor`]
735 pub fn create_tensors(
736 &self,
737 descriptors: Vec<(MemoryLayoutDescriptor, Bytes)>,
738 ) -> Vec<MemoryLayout> {
739 let (descriptors, data) = descriptors.into_iter().unzip();
740
741 self.do_create(descriptors, data)
742 }
743
744 fn do_empty(&self, descriptors: Vec<MemoryLayoutDescriptor>) -> Vec<MemoryLayout> {
745 let stream_id = self.stream_id();
746 let (handle_base, layouts) =
747 self.utilities
748 .layout_policy
749 .apply(self.service_id(), stream_id, &descriptors);
750
751 let (size, memory) = (handle_base.size(), handle_base.memory);
752 self.device.submit(move |server| {
753 server.initialize_memory(memory, size, stream_id);
754 });
755
756 layouts
757 }
758
759 /// Reserves `size` bytes in the storage, and returns a handle over them.
760 pub fn empty(&self, size: usize) -> Handle {
761 let shape: Shape = [size].into();
762 let descriptor = MemoryLayoutDescriptor::new(MemoryLayoutStrategy::Contiguous, shape, 1);
763 self.do_empty(vec![descriptor]).remove(0).memory
764 }
765
766 /// Reserves `shape` in the storage, and returns a tensor handle for it.
767 /// See [`Client::create_tensor`]
768 pub fn empty_tensor(&self, shape: Shape, elem_size: usize) -> MemoryLayout {
769 let descriptor =
770 MemoryLayoutDescriptor::new(MemoryLayoutStrategy::Optimized, shape, elem_size);
771 self.do_empty(vec![descriptor]).remove(0)
772 }
773
774 /// Reserves all `shapes` in a single storage buffer, and returns the handles for them.
775 /// See [`Client::create_tensor`]
776 pub fn empty_tensors(&self, descriptors: Vec<MemoryLayoutDescriptor>) -> Vec<MemoryLayout> {
777 self.do_empty(descriptors)
778 }
779
780 /// Marks the given [Bytes] as being a staging buffer, maybe transferring it to pinned memory
781 /// for faster data transfer with compute device.
782 ///
783 /// TODO: This blocks the compute queue, so it will drop the compute utilization.
784 pub fn staging<'a, I>(&self, bytes: I, file_only: bool)
785 where
786 I: Iterator<Item = &'a mut Bytes>,
787 {
788 let has_staging = |b: &Bytes| match b.property() {
789 AllocationProperty::Pinned => false,
790 AllocationProperty::File => true,
791 // A lazily device-backed buffer materializes on access and is staged (if needed)
792 // by the backend write path, so don't force it into a host staging buffer here.
793 AllocationProperty::Device => false,
794 AllocationProperty::Native | AllocationProperty::Other => !file_only,
795 };
796
797 let mut to_be_updated = Vec::new();
798 let sizes = bytes
799 .filter_map(|b| match has_staging(b) {
800 true => {
801 let len = b.len();
802 to_be_updated.push(b);
803 Some(len)
804 }
805 false => None,
806 })
807 .collect::<Vec<usize>>();
808
809 if sizes.is_empty() {
810 return;
811 }
812
813 let stream_id = self.stream_id();
814 let sizes = sizes.to_vec();
815 let stagings = self
816 .device
817 .submit_blocking(move |server| server.staging(&sizes, stream_id))
818 .unwrap_or_resume();
819
820 let stagings = match stagings {
821 Ok(val) => val,
822 Err(_) => return,
823 };
824
825 to_be_updated
826 .into_iter()
827 .zip(stagings)
828 .for_each(|(b, mut staging)| {
829 b.copy_into(&mut staging);
830 core::mem::swap(b, &mut staging);
831 });
832 }
833
834 /// Transfer data from one client to another.
835 ///
836 /// `src` must be this client's. The bytes go device to device when both
837 /// clients are of the same runtime and it has a collective transport;
838 /// otherwise, and always across runtimes, they go through the host.
839 #[cfg_attr(
840 feature = "tracing",
841 tracing::instrument(level = "trace", skip(self, src, dst_server))
842 )]
843 pub fn to_client(&mut self, src: Handle, dst_server: &Self, dtype: ElemType) -> Handle {
844 self.expect_local(&src.clone().binding());
845 let shape = [src.size_in_used() as usize];
846 let src_descriptor = src.copy_descriptor(shape.into(), [1].into(), 1);
847
848 let same_runtime = dst_server.service_id().service == self.service_id().service;
849 if self.has_device_transport() && same_runtime {
850 self.to_client_tensor(src_descriptor, dst_server, dtype)
851 } else {
852 let alloc_desc = MemoryLayoutDescriptor::new(
853 MemoryLayoutStrategy::Contiguous,
854 src_descriptor.shape.clone(),
855 src_descriptor.elem_size,
856 );
857 self.change_client_sync(src_descriptor, alloc_desc, dst_server)
858 .memory
859 }
860 }
861
862 /// Perform an `all_reduce` operation on the given devices.
863 #[cfg_attr(
864 feature = "tracing",
865 tracing::instrument(level = "trace", skip(self, device_ids))
866 )]
867 pub fn ensure_init_collective(&mut self, device_ids: Vec<DeviceId>) {
868 self.expect_device_transport(Collective::CommInit);
869 let comm_id = CommunicationId::from(device_ids.clone());
870 let is_comms_init = self.utilities.initialized_comms.read().contains(&comm_id);
871 if !is_comms_init {
872 self.device
873 .submit(move |server| server.comm_init(device_ids).unwrap());
874 let mut initialized_comms = self.utilities.initialized_comms.write();
875 initialized_comms.insert(comm_id);
876 // Flush immediately so other devices aren't blocked waiting on this initialization.
877 self.device.flush_queue();
878 }
879 }
880
881 /// Whether this runtime moves data between its devices itself. Without it, `to_client`
882 /// copies through the host and the collectives refuse.
883 pub fn has_device_transport(&self) -> bool {
884 self.utilities.server_comm_enabled
885 }
886
887 /// Panics on the caller when the runtime has no device transport.
888 fn expect_device_transport(&self, operation: Collective) {
889 // The server refuses too, but on the device thread, where the channel turns the panic into
890 // a log line and the caller only sees a later read fail.
891 if !self.has_device_transport() {
892 let alternative = match operation {
893 Collective::Send | Collective::Recv => "; `to_client` copies through the host",
894 _ => "",
895 };
896 panic!(
897 "Can't use `{operation}` on {}, which has no transport between its devices{alternative}",
898 self.utilities.name
899 );
900 }
901 }
902
903 /// Wait on the communication stream.
904 #[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(self)))]
905 pub fn sync_collective(&self) {
906 if DeviceHandle::<dyn Server>::is_blocking() {
907 panic!("Can't use `sync_collective` with a blocking device handle");
908 }
909 // Nothing was sent between devices, so there is nothing to wait for.
910 if !self.has_device_transport() {
911 return;
912 }
913 let stream_id = self.stream_id();
914
915 self.device.submit(move |server| {
916 // Logged rather than unwrapped: a panic on the server thread is
917 // reduced to a log line by the channel's catch_unwind anyway, so
918 // report deliberately instead of through a swallowed unwind.
919 if let Err(err) = server.sync_collective(stream_id) {
920 log::error!("sync_collective failed: {err}");
921 }
922 });
923
924 // We don't actually need or want to sync the server here, but we need to make sure any
925 // task enqueued on the communication channel is done.
926 self.device.flush_queue();
927 }
928
929 /// Perform an `all_reduce` operation on the given devices.
930 #[cfg_attr(
931 feature = "tracing",
932 tracing::instrument(level = "trace", skip(self, src, dst, dtype, device_ids, op))
933 )]
934 pub fn all_reduce(
935 &mut self,
936 src: Handle,
937 dst: Handle,
938 dtype: ElemType,
939 device_ids: Vec<DeviceId>,
940 op: ReduceOperation,
941 ) {
942 if DeviceHandle::<dyn Server>::is_blocking() {
943 panic!("Can't use `all_reduce` with a blocking device handle");
944 }
945 self.expect_device_transport(Collective::AllReduce);
946
947 let stream_id = self.stream_id();
948 let src = src.binding();
949 let dst = dst.binding();
950 self.expect_local(&src);
951 self.expect_local(&dst);
952
953 self.ensure_init_collective(device_ids.clone());
954
955 self.device.submit(move |server| {
956 // The report lives on the buffers: a refused or failed reduce has
957 // tainted the destination, so the read that consumes it fails on
958 // the root cause. The log is the eager half of that report — an
959 // unwrap here would only be reduced to a warn by the channel's
960 // catch_unwind, with the taint doing the real work either way.
961 if let Err(err) = server.all_reduce(src, dst, dtype, stream_id, op, device_ids) {
962 log::error!("all_reduce failed; the destination carries the failure: {err}");
963 }
964 });
965 }
966
967 /// Transfer data from one client to another
968 ///
969 /// Make sure the source description can be read in a contiguous manner.
970 #[cfg_attr(
971 feature = "tracing",
972 tracing::instrument(level = "trace", skip(self, src_descriptor, dst_server))
973 )]
974 pub fn to_client_tensor(
975 &mut self,
976 src_descriptor: CopyDescriptor,
977 dst_server: &Self,
978 dtype: ElemType,
979 ) -> Handle {
980 self.expect_device_transport(Collective::Send);
981 self.expect_local(&src_descriptor.handle);
982 let stream_id_src = self.stream_id();
983 let stream_id_dst = dst_server.stream_id();
984
985 let device_id_src = self.device.device_id();
986 let device_id_dst = dst_server.device.device_id();
987
988 let mut dst_server = dst_server.clone();
989 let handle = Handle::new(
990 dst_server.service_id(),
991 stream_id_dst,
992 src_descriptor.handle.size_in_used(),
993 );
994 let handle_cloned = handle.clone();
995
996 let device_ids = vec![device_id_src, device_id_dst];
997 self.ensure_init_collective(device_ids.clone());
998 dst_server.ensure_init_collective(device_ids);
999
1000 self.device.submit(move |server_src| {
1001 // A refused send has no local buffer to answer for, so the log is
1002 // the whole local report. The peer's posted recv is left waiting
1003 // on its communication stream — the recv cannot be recalled from
1004 // here, and cross-device failure propagation needs a design pass
1005 // of its own — so the wedge is named loudly rather than hidden
1006 // behind a swallowed unwrap.
1007 if let Err(err) = server_src.send(src_descriptor, dtype, stream_id_src, device_id_dst) {
1008 log::error!(
1009 "send to {device_id_dst:?} failed; the peer's recv is left waiting: {err}"
1010 );
1011 }
1012 });
1013
1014 dst_server.device.submit(move |server_dst| {
1015 // A failed recv taints the destination handle, so the read that
1016 // consumes this transfer fails on the cause.
1017 if let Err(err) = server_dst.recv(handle_cloned, dtype, stream_id_dst, device_id_src) {
1018 log::error!(
1019 "recv from {device_id_src:?} failed; the destination carries the failure: {err}"
1020 );
1021 return;
1022 }
1023 if let Err(err) = server_dst.sync_collective(stream_id_dst) {
1024 log::error!("sync_collective failed: {err}");
1025 }
1026 });
1027
1028 // `ServerCommunication::send` and`ServerCommunication::recv` are blocking: they each wait for the corresponding recv/send
1029 // call to be made. We flush the operations right away so that the neither server ends up in a deadlock.
1030 // The actual data transfer is still executed asynchronously on the communication stream.
1031 self.device.flush_queue();
1032 dst_server.device.flush_queue();
1033
1034 handle
1035 }
1036
1037 #[track_caller]
1038 #[cfg_attr(feature = "tracing", tracing::instrument(level="trace",
1039 skip(self, kernel, bindings),
1040 fields(
1041 kernel.name = %kernel.name(),
1042 kernel.id = %kernel.id(),
1043 )
1044 ))]
1045 unsafe fn launch_inner(
1046 &self,
1047 kernel: Box<dyn CubeKernel>,
1048 count: CubeCount,
1049 bindings: KernelArguments,
1050 stream_id: StreamId,
1051 ) {
1052 // No work, and some drivers reject a zero grid dim.
1053 if let CubeCount::Static(x, y, z) = &count
1054 && (*x == 0 || *y == 0 || *z == 0)
1055 {
1056 return;
1057 }
1058 if let CubeCount::Dynamic(binding) = &count {
1059 self.expect_local(binding);
1060 }
1061 for resource in &bindings.resources {
1062 self.expect_local(match resource {
1063 KernelResource::Buffer(binding) => binding,
1064 KernelResource::TensorMap(map) => &map.binding,
1065 });
1066 }
1067
1068 crate::launched::note(|| kernel.id());
1069
1070 // Decided here, on the issuing thread, because that is the only place
1071 // that still knows whether this launch is an autotune measurement — by
1072 // the time it reaches the server thread, that context is gone.
1073 let launch_mode = crate::dry_run::launch_mode();
1074
1075 let level = self.utilities.logger.profile_level();
1076
1077 // Before the submit, on the issuing thread: this is the last point at
1078 // which the caller's own context still exists, and attributing a
1079 // launch to what caused it is the whole reason the hook is here rather
1080 // than beside the logger's aggregation.
1081 if crate::logging::is_observing() {
1082 crate::logging::notify_launch(kernel.name());
1083 }
1084
1085 // An observer asking for timing gets the profiled path even with the
1086 // profiling logger off — the two are separate readers of the same
1087 // measurement, and making one depend on the other's configuration
1088 // would mean a caller could not time launches without also logging
1089 // them somewhere it did not choose.
1090 let observed_timing = crate::logging::timing_wanted();
1091
1092 match level {
1093 None | Some(ProfileLevel::ExecutionOnly) if !observed_timing => {
1094 let utilities = self.utilities.clone();
1095 self.device.submit(move |state| {
1096 let execution_info = if matches!(level, Some(ProfileLevel::ExecutionOnly)) {
1097 Some(profile_label(kernel.name(), &kernel.id()))
1098 } else {
1099 None
1100 };
1101
1102 unsafe { state.launch(kernel, count, bindings, stream_id, launch_mode) };
1103
1104 if let Some(info) = execution_info {
1105 utilities.logger.register_execution(info);
1106 }
1107 });
1108 }
1109 level => {
1110 let name = kernel.name();
1111 let kernel_id = kernel.id();
1112 let context = self.device.clone();
1113 // The arguments travel through a slot the profiled closure
1114 // empties, because a profile can be refused — a graph capture
1115 // window refuses one on the spot — and a refusal must hand the
1116 // launch back: dropping a kernel because its measurement could
1117 // not start would turn a missing timing into a missing
1118 // computation.
1119 let slot = Arc::new(cubecl_environment::sync::Mutex::new(Some((
1120 kernel,
1121 count.clone(),
1122 bindings,
1123 ))));
1124 let to_launch = slot.clone();
1125 let profiled = self.profile(
1126 move || {
1127 let (kernel, count, bindings) = to_launch
1128 .lock()
1129 .take()
1130 .expect("filled right above, emptied only here");
1131 context
1132 .submit_blocking(move |state| unsafe {
1133 state.launch(kernel, count, bindings, stream_id, launch_mode)
1134 })
1135 .unwrap_or_resume()
1136 },
1137 name,
1138 );
1139 let profile = match profiled {
1140 Ok(((), profile)) => profile,
1141 Err(err) => {
1142 // The logger's timing levels opted into profiling and
1143 // keep their loud failure. Only the observer's timing
1144 // degrades: it asked for a measurement, and a refused
1145 // measurement must not take the launch down with it.
1146 if !matches!(level, None | Some(ProfileLevel::ExecutionOnly)) {
1147 panic!("{err:?}");
1148 }
1149 match slot.lock().take() {
1150 // The refusal came before the closure ran, so the
1151 // kernel was never submitted. Launch it the way an
1152 // unobserved run would have.
1153 Some((kernel, count, bindings)) => {
1154 let utilities = self.utilities.clone();
1155 let kernel_id = kernel.id();
1156 self.device.submit(move |state| {
1157 unsafe {
1158 state.launch(
1159 kernel,
1160 count,
1161 bindings,
1162 stream_id,
1163 launch_mode,
1164 )
1165 };
1166 if matches!(level, Some(ProfileLevel::ExecutionOnly)) {
1167 let info = profile_label(name, &kernel_id);
1168 utilities.logger.register_execution(info);
1169 }
1170 });
1171 }
1172 // The closure ran, so the kernel was submitted;
1173 // only its measurement was lost.
1174 None => {
1175 if matches!(level, Some(ProfileLevel::ExecutionOnly)) {
1176 let info = profile_label(name, &kernel_id);
1177 self.utilities.logger.register_execution(info);
1178 }
1179 }
1180 }
1181 log::warn!(
1182 "Skipped timing a launch of `{name}` for its observer: the profile was refused ({err:?})"
1183 );
1184 return;
1185 }
1186 };
1187 // The observer alone: it takes the measurement unread, so the
1188 // kernels around this one keep running back to back. An observer
1189 // does not change what the logger writes, and `ExecutionOnly` is
1190 // documented as the kernels that ran without their timings, so
1191 // it logs the execution and never the profile.
1192 if observed_timing && matches!(level, None | Some(ProfileLevel::ExecutionOnly)) {
1193 crate::logging::notify_profiled(name, profile);
1194 if matches!(level, Some(ProfileLevel::ExecutionOnly)) {
1195 let info = profile_label(name, &kernel_id);
1196 self.utilities.logger.register_execution(info);
1197 }
1198 return;
1199 }
1200 // Both read this measurement, and a measurement is read once.
1201 // The observer is told first because resolving consumes it: the
1202 // logger's copy is the one that can be deferred, an observer's
1203 // cannot be recovered afterwards.
1204 let profile = if observed_timing {
1205 // The observer asked to keep its measurements and cannot:
1206 // the logger reads this one, so the observer is told a
1207 // duration and its kernels stop overlapping.
1208 crate::logging::warn_logger_takes_deferred_measurements();
1209 // Comes back already resolved rather than measured again:
1210 // the logger and the observer are two readers of one
1211 // measurement, and a second would not be the same launch.
1212 crate::logging::read_and_notify_timed(name, profile)
1213 } else {
1214 profile
1215 };
1216 // Every level left times its launches: the ones that don't
1217 // either never took this path or returned above.
1218 let info = match level {
1219 Some(ProfileLevel::Full) => {
1220 format!("{name}: {kernel_id} CubeCount {count:?}")
1221 }
1222 _ => profile_label(name, &kernel_id),
1223 };
1224 self.utilities.logger.register_profiled(info, profile);
1225 }
1226 }
1227 }
1228
1229 /// Launches the `kernel` with the given `bindings`.
1230 #[track_caller]
1231 pub fn launch(&self, kernel: Box<dyn CubeKernel>, count: CubeCount, bindings: KernelArguments) {
1232 unsafe { self.launch_inner(kernel, count, bindings, self.stream_id()) }
1233 }
1234
1235 /// Whether the bytes behind `handles` can be trusted, right now and with
1236 /// no barrier: the claim check a read makes, without the read. One lookup
1237 /// per handle, so a fusion layer or an autotuner can recover per tensor
1238 /// instead of tearing down a device.
1239 ///
1240 /// Instant means enqueue-time failures only — a compile or binding
1241 /// failure is visible here immediately, a device fault is not until the
1242 /// queue drains. [`sync_buffers`](Self::sync_buffers) is the complete
1243 /// answer; [`read_one`](Self::read_one) is that plus the copy.
1244 ///
1245 /// # Errors
1246 ///
1247 /// [`ServerError::Several`] naming every failure these buffers carry, each
1248 /// once however many carry it. The bytes are gone, so there is nothing to
1249 /// retry: this is the answer, not a hint.
1250 pub fn check<'a>(
1251 &self,
1252 handles: impl IntoIterator<Item = &'a Handle>,
1253 ) -> Result<(), ServerError> {
1254 let bindings = self.bindings(handles)?;
1255 let stream_id = self.stream_id();
1256 self.device
1257 .submit_blocking(move |server| server.check(bindings, stream_id))
1258 .unwrap_or_resume()
1259 }
1260
1261 /// Flush all outstanding commands.
1262 pub fn flush(&self) -> Result<(), ServerError> {
1263 let stream_id = self.stream_id();
1264
1265 self.device
1266 .submit_blocking(move |server| server.flush(stream_id))
1267 .unwrap_or_resume()
1268 }
1269
1270 /// Prepare this client's stream for a graph capture (see
1271 /// [`Server::graph_prepare`]) — enable the persistent pool + capture
1272 /// recording. Call this **before** the warmup run, then
1273 /// [`start_capture`](Self::start_capture) around the run to record.
1274 pub fn graph_prepare(&self) -> Result<(), ServerError> {
1275 let stream_id = self.stream_id();
1276 self.device
1277 .submit_blocking(move |server| server.graph_prepare(stream_id))
1278 .unwrap_or_resume()
1279 }
1280
1281 /// Begin recording launches on this client's stream into a graph rather
1282 /// than executing them (see [`Server::begin_capture`]). Pin the
1283 /// client to a dedicated stream with [`set_stream`](Self::set_stream), then
1284 /// [`graph_prepare`](Self::graph_prepare) and warm up first.
1285 ///
1286 /// Between this and [`stop_capture`](Self::stop_capture) the window records
1287 /// launches and nothing else: reading, syncing or profiling the stream is
1288 /// refused, and so is writing to a handle — a recorded graph cannot carry a
1289 /// host copy, so feed fresh inputs by writing *between* replays instead. A
1290 /// refused write is reported late, by failing `stop_capture`, rather than
1291 /// handing back a graph that silently skips it. Fresh allocation inside the
1292 /// window is fatal on a hardware-graph backend and merely wasteful on a
1293 /// software-graph one, which is what the warmup run exists to avoid.
1294 ///
1295 /// Returns an error on backends without graph support.
1296 pub fn start_capture(&self) -> Result<(), ServerError> {
1297 let stream_id = self.stream_id();
1298 self.device
1299 .submit_blocking(move |server| server.begin_capture(stream_id))
1300 .unwrap_or_resume()
1301 }
1302
1303 /// Stop recording and return the captured graph, ready to
1304 /// [`replay`](Graph::replay).
1305 pub fn stop_capture(&self) -> Result<Graph, ServerError> {
1306 let stream_id = self.stream_id();
1307 let id = self
1308 .device
1309 .submit_blocking(move |server| server.end_capture(stream_id))
1310 .unwrap_or_resume()?;
1311
1312 Ok(Graph {
1313 inner: Arc::new(GraphHandle {
1314 id,
1315 device: self.device.clone(),
1316 stream_id,
1317 }),
1318 })
1319 }
1320
1321 /// Wait for the completion of every task in the server.
1322 ///
1323 /// The barrier alone, which also reports a device fault — the only failure
1324 /// left that no buffer can report. A launch failure is not this sync's to
1325 /// report: it lives on the buffers the launch never wrote and surfaces on
1326 /// any read, [`check`](Self::check) or
1327 /// [`sync_buffers`](Self::sync_buffers) of those.
1328 pub fn sync(&self) -> DynFut<Result<(), ServerError>> {
1329 self.sync_buffers([])
1330 }
1331
1332 /// The barrier, and then an answer for `handles`.
1333 ///
1334 /// [`sync`](Self::sync) first, so a device fault counts, and then the
1335 /// claim check a read would have made — a read without the read, for the
1336 /// caller that needs to know its work produced something trustworthy and
1337 /// does not want to pull it to the host to find out.
1338 ///
1339 /// # Errors
1340 ///
1341 /// The device fault the barrier found, or [`ServerError::Several`] naming
1342 /// every failure these buffers carry.
1343 pub fn sync_buffers<'a>(
1344 &self,
1345 handles: impl IntoIterator<Item = &'a Handle>,
1346 ) -> DynFut<Result<(), ServerError>> {
1347 let stream_id = self.stream_id();
1348 let bindings = match self.bindings(handles) {
1349 Ok(bindings) => bindings,
1350 Err(err) => return Box::pin(core::future::ready(Err(err))),
1351 };
1352
1353 let fut = self
1354 .device
1355 .submit_blocking(move |server| server.sync(bindings, stream_id))
1356 .unwrap_or_resume();
1357
1358 self.utilities.logger.profile_summary();
1359
1360 fut
1361 }
1362
1363 /// The bindings `handles` name, which is what crosses to the device
1364 /// thread: a `Handle` borrows, and the closure that answers for it runs
1365 /// somewhere else.
1366 fn bindings<'a>(
1367 &self,
1368 handles: impl IntoIterator<Item = &'a Handle>,
1369 ) -> Result<Vec<BufferBinding>, ServerError> {
1370 handles
1371 .into_iter()
1372 .map(|handle| {
1373 let binding = handle.clone().binding();
1374 self.local(&binding)?;
1375 Ok(binding)
1376 })
1377 .collect()
1378 }
1379
1380 /// Get the features supported by the compute server.
1381 pub fn properties(&self) -> &DeviceProperties {
1382 &self.utilities.properties
1383 }
1384
1385 /// Get the features supported by the compute server.
1386 pub fn features(&self) -> &Features {
1387 &self.utilities.properties.features
1388 }
1389
1390 /// The device properties, shared: what a kernel keeps to expand itself
1391 /// on the device thread without holding the client.
1392 pub fn properties_shared(&self) -> Arc<DeviceProperties> {
1393 self.utilities.properties.clone()
1394 }
1395
1396 /// What the target this client compiles for guarantees about its own
1397 /// instructions, resolved once when the device came up.
1398 pub fn target_properties(&self) -> &TargetProperties {
1399 &self.utilities.target_properties
1400 }
1401
1402 /// The target properties, shared: the other half of what a kernel keeps to
1403 /// expand itself on the device thread without naming a runtime.
1404 ///
1405 /// Cloning this is one atomic increment, which is why the generated launch
1406 /// functions can afford to do it per launch where calling
1407 /// [`Runtime::target_properties`] again would not be.
1408 ///
1409 /// [`Runtime::target_properties`]: crate::runtime::Runtime::target_properties
1410 pub fn target_properties_shared(&self) -> Arc<TargetProperties> {
1411 self.utilities.target_properties.clone()
1412 }
1413
1414 /// Total memory usage across all streams on this client's device.
1415 ///
1416 /// The closure iterates the server's `stream_ids()` and folds each
1417 /// per-stream `memory_usage(id)` with `MemoryUsage::combine`, so the
1418 /// result is correct regardless of which thread queries it.
1419 pub fn memory_usage(&self) -> MemoryUsage {
1420 self.device
1421 .submit_blocking(move |server| {
1422 server
1423 .stream_ids()
1424 .into_iter()
1425 .fold(MemoryUsage::default(), |acc, id| {
1426 acc.combine(server.memory_usage(id))
1427 })
1428 })
1429 .unwrap_or_resume()
1430 }
1431
1432 /// Structured per-pool report of the **calling stream's** main GPU memory:
1433 /// each pool's shape, usage, and high-water marks, in allocation-routing
1434 /// order.
1435 ///
1436 /// The read side of a measured memory plan — install a layout with
1437 /// [`install_memory_pools`](Self::install_memory_pools), measure under a
1438 /// [`DryRun`](crate::dry_run::DryRun), cap at the observed peaks; the full
1439 /// cycle is on [`MemoryReport`].
1440 ///
1441 /// Unlike [`memory_usage`](Self::memory_usage), which aggregates across
1442 /// streams, this reads one stream: pools are per stream, and a plan is
1443 /// measured and installed on the stream that runs the workload.
1444 pub fn memory_report(&self) -> MemoryReport {
1445 let stream_id = self.stream_id();
1446 self.device
1447 .submit_blocking(move |server| server.memory_report(stream_id))
1448 .unwrap_or_resume()
1449 }
1450
1451 /// Write a snapshot of the calling stream's [memory
1452 /// report](Self::memory_report) to the environment's records, under
1453 /// `label`. Nothing is read when the environment records nothing.
1454 pub fn record_memory(&self, label: &str) {
1455 if !cubecl_environment::records::enabled() {
1456 return;
1457 }
1458 let record = crate::memory_management::MemoryRecord {
1459 label: label.into(),
1460 report: self.memory_report(),
1461 };
1462 cubecl_environment::records::write(
1463 cubecl_environment::records::RecordEffect::Observed,
1464 &record,
1465 );
1466 }
1467
1468 /// Change the memory allocation mode.
1469 ///
1470 /// # Safety
1471 ///
1472 /// This function isn't thread safe and might create memory leaks.
1473 pub unsafe fn allocation_mode(&self, mode: MemoryAllocationMode) {
1474 let stream_id = self.stream_id();
1475 self.device
1476 .submit(move |server| server.allocation_mode(mode, stream_id));
1477 }
1478
1479 /// Ask the client to release memory that it can release.
1480 ///
1481 /// Nb: Results will vary on what the memory allocator deems beneficial,
1482 /// so it's not guaranteed any memory is freed.
1483 pub fn memory_cleanup(&self) {
1484 self.device.submit(move |server| {
1485 for id in server.stream_ids() {
1486 server.memory_cleanup(id);
1487 }
1488 });
1489 }
1490
1491 /// Install a new dynamic-pool layout for the device's main GPU memory.
1492 ///
1493 /// This replaces the pools themselves, not just a setting they read. It
1494 /// lands in two places:
1495 ///
1496 /// - **The calling stream's pools are rebuilt in place**, discarding the
1497 /// old ones — which is why it only happens when nothing is live in them,
1498 /// and why the high-water marks in
1499 /// [`memory_report`](Self::memory_report) start over.
1500 /// - **The layout becomes the one every stream created afterwards is
1501 /// built with.** Other streams that already exist keep theirs; memory is
1502 /// per stream, and rebuilding a stream this call is not synchronized
1503 /// with would swap pools under its live slices.
1504 ///
1505 /// Pool layouts are a purely programmatic, runtime setting — there is no
1506 /// config-file pathway — sized per workload (e.g. per model, just before
1507 /// loading it), so install at a quiescent point such as right after
1508 /// unloading a model. Auxiliary pools (pinned CPU, staging, uniforms) and
1509 /// the persistent pool are never affected.
1510 ///
1511 /// # Errors
1512 ///
1513 /// [`PoolsInUse`](InstallMemoryPoolsError::PoolsInUse) when the current
1514 /// stream kept its old layout because something was still live in its
1515 /// pools — e.g. a garbage-collection task that has not released its
1516 /// cross-stream pins yet, which can lag behind an explicit
1517 /// [`memory_cleanup`](Self::memory_cleanup). Nothing is disturbed, the
1518 /// layout still applies to streams created afterwards, and retrying after
1519 /// the remaining work drains rebuilds the current stream too.
1520 ///
1521 /// [`Unsupported`](InstallMemoryPoolsError::Unsupported) from a runtime
1522 /// with no configurable pools, where retrying will never succeed.
1523 ///
1524 /// # Panics
1525 ///
1526 /// Panics if the layout is invalid (empty list, too many pools, zero page
1527 /// size, slice larger than page, cap smaller than page, unavailable
1528 /// preset) — that is a bad layout literal rather than a runtime condition,
1529 /// and an explicit layout that cannot be honored must not be silently
1530 /// replaced.
1531 pub fn install_memory_pools(
1532 &self,
1533 pools: &MemoryPoolsConfig,
1534 ) -> Result<(), InstallMemoryPoolsError> {
1535 let config =
1536 match MemoryConfiguration::default().resolve(Some(pools), &self.properties().memory) {
1537 Ok(config) => config,
1538 Err(err) => panic!("Invalid memory pools configuration: {err}"),
1539 };
1540 let stream_id = self.stream_id();
1541 self.device
1542 .submit_blocking(move |server| server.install_memory_pools(config, stream_id))
1543 .unwrap_or_resume()
1544 }
1545
1546 /// Open a profiling window at the current position of the calling stream.
1547 ///
1548 /// Prefer the bracketed [`profile`](Self::profile), which also holds the
1549 /// device for the closure. This pair is for a caller that cannot bracket the
1550 /// work in a closure — a lazy queue drained on another thread, say — and
1551 /// only knows *when* on the stream its window opens and closes.
1552 ///
1553 /// The window keeps the stream it was opened on, and
1554 /// [`profile_end`](Self::profile_end) closes it there whichever thread
1555 /// calls it. Nothing keeps other streams' work out of the window.
1556 ///
1557 /// An open window costs something on every backend and stays open until it
1558 /// is ended or [abandoned](Self::profile_abandon), so a caller that bails
1559 /// out between the two calls has to abandon it.
1560 pub fn profile_start(&self) -> Result<ProfileWindow, ProfileError> {
1561 let stream_id = self.stream_id();
1562 let token = self
1563 .device
1564 .submit_blocking(move |server| server.start_profile(stream_id))
1565 .unwrap_or_resume()
1566 .map_err(|err| ProfileError::from(&err))?;
1567 Ok(ProfileWindow { stream_id, token })
1568 }
1569
1570 /// Close `window` at the current position of the stream it was opened on.
1571 pub fn profile_end(&self, window: ProfileWindow) -> Result<ProfileDuration, ProfileError> {
1572 let ProfileWindow { stream_id, token } = window;
1573 self.device
1574 .submit_blocking(move |server| server.end_profile(stream_id, token))
1575 .unwrap_or_resume()
1576 }
1577
1578 /// Drop `window` without measuring it, for a caller that will never reach
1579 /// [`profile_end`](Self::profile_end), such as an error path between the
1580 /// two calls.
1581 ///
1582 /// Does not wait for the server to drop it, but does flush, because this
1583 /// is usually a caller's last word: an abandon left sitting in the queue
1584 /// holds the window open for exactly as long as it is the only thing in
1585 /// there, which is the case it exists for.
1586 pub fn profile_abandon(&self, window: ProfileWindow) {
1587 let ProfileWindow { stream_id, token } = window;
1588 self.device
1589 .submit(move |server| server.abandon_profile(stream_id, token));
1590 self.device.flush_queue();
1591 }
1592
1593 /// Measure the execution time of some inner operations.
1594 #[track_caller]
1595 pub fn profile<O: Send + 'static>(
1596 &self,
1597 func: impl FnOnce() -> O + Send,
1598 #[allow(unused)] func_name: &str,
1599 ) -> Result<(O, ProfileDuration), ProfileError> {
1600 // Get the outer caller. For execute() this points straight to the
1601 // cube kernel. For general profiling it points to whoever calls profile.
1602 #[cfg(feature = "profile-tracy")]
1603 let location = std::panic::Location::caller();
1604
1605 // Make a CPU span. If the server has system profiling this is all you need.
1606 #[cfg(feature = "profile-tracy")]
1607 let _span = tracy_client::Client::running().unwrap().span_alloc(
1608 None,
1609 func_name,
1610 location.file(),
1611 location.line(),
1612 0,
1613 );
1614
1615 let stream_id = self.stream_id();
1616
1617 #[cfg(feature = "profile-tracy")]
1618 let gpu_span = if self.utilities.properties.timing_method == TimingMethod::Device {
1619 let gpu_span = self
1620 .utilities
1621 .gpu_client
1622 .span_alloc(func_name, "profile", location.file(), location.line())
1623 .unwrap();
1624 Some(gpu_span)
1625 } else {
1626 None
1627 };
1628
1629 let device = self.device.clone();
1630 #[allow(unused_mut, reason = "Used in profile-tracy")]
1631 let mut result = self
1632 .device
1633 .exclusive(move || {
1634 // We first get mut access to the server to create a token.
1635 // Then we free to server, since it's going to be accessed in `func()`.
1636 let token =
1637 match device.submit_blocking(move |server| server.start_profile(stream_id)) {
1638 Ok(token) => match token {
1639 Ok(token) => token,
1640 Err(err) => return Err(err),
1641 },
1642 Err(err) => {
1643 return Err(ServerError::Generic {
1644 reason: alloc::format!(
1645 "Can't start profiling because of a call error: {err:?}"
1646 ),
1647 backtrace: BackTrace::capture(),
1648 });
1649 }
1650 };
1651
1652 // We execute `func()` which will recursibly access the server.
1653 let out = func();
1654
1655 // Finally we get the result from the token.
1656 let result = device
1657 .submit_blocking(move |server| {
1658 let mut result = server.end_profile(stream_id, token);
1659
1660 match result {
1661 Ok(result) => Ok((out, result)),
1662 Err(err) => Err(err),
1663 }
1664 })
1665 .unwrap_or_resume();
1666
1667 Ok(result)
1668 })
1669 .unwrap_or_resume()
1670 .map_err(|err| ProfileError::from(&err))?;
1671
1672 #[cfg(feature = "profile-tracy")]
1673 if let Some(mut gpu_span) = gpu_span {
1674 gpu_span.end_zone();
1675 let epoch = self.utilities.epoch_time;
1676 // Add in the work to upload the timestamp data.
1677 result = result.map(|(o, result)| {
1678 (
1679 o,
1680 ProfileDuration::new(
1681 alloc::boxed::Box::pin(async move {
1682 let ticks = result.resolve().await;
1683 // A window that carried no measurement has no span
1684 // to place: `resolve` answers `None` rather than a
1685 // zero so nothing reports it as an instant at the
1686 // epoch.
1687 if let Some(ticks) = &ticks {
1688 let start_duration =
1689 ticks.start_duration_since(epoch).as_nanos() as i64;
1690 let end_duration =
1691 ticks.end_duration_since(epoch).as_nanos() as i64;
1692 gpu_span.upload_timestamp_start(start_duration);
1693 gpu_span.upload_timestamp_end(end_duration);
1694 }
1695 ticks
1696 }),
1697 TimingMethod::Device,
1698 ),
1699 )
1700 });
1701 }
1702
1703 result
1704 }
1705
1706 /// Transfer data from one client to another
1707 #[cfg_attr(
1708 feature = "tracing",
1709 tracing::instrument(
1710 level = "trace",
1711 skip(self, src_descriptor, alloc_descriptor, dst_server)
1712 )
1713 )]
1714 fn change_client_sync(
1715 &self,
1716 src_descriptor: CopyDescriptor,
1717 alloc_descriptor: MemoryLayoutDescriptor,
1718 dst_server: &Self,
1719 ) -> MemoryLayout {
1720 let shape = src_descriptor.shape.clone();
1721 let elem_size = src_descriptor.elem_size;
1722 let stream_id_src = self.stream_id();
1723 let stream_id_dst = dst_server.stream_id();
1724
1725 let read = self
1726 .device
1727 .submit_blocking(move |server| server.read(vec![src_descriptor], stream_id_src))
1728 .unwrap_or_resume();
1729
1730 let mut data = cubecl_environment::future::block_on(read).unwrap();
1731
1732 // The allocation belongs to the destination: it is initialized and
1733 // written there, so it takes that device's layout policy, stream and
1734 // `ServiceId`. Stamping it from `self` would hand back a handle the
1735 // destination refuses as foreign.
1736 let (handle_base, mut layouts) = dst_server.utilities.layout_policy.apply(
1737 dst_server.service_id(),
1738 stream_id_dst,
1739 &[alloc_descriptor],
1740 );
1741 let alloc = layouts.remove(0);
1742
1743 let desc_descriptor = CopyDescriptor {
1744 handle: handle_base.clone().binding(),
1745 shape,
1746 strides: alloc.strides.clone(),
1747 elem_size,
1748 };
1749
1750 let (size, memory) = (handle_base.size(), handle_base.memory);
1751 dst_server.device.submit(move |server| {
1752 server.initialize_memory(memory, size, stream_id_dst);
1753 server.write(vec![(desc_descriptor, data.remove(0))], stream_id_dst)
1754 });
1755
1756 alloc
1757 }
1758
1759 /// Returns all vector sizes that are useful to perform optimal IO operation on the given element.
1760 pub fn io_optimized_vector_sizes(
1761 &self,
1762 size: usize,
1763 ) -> impl Iterator<Item = VectorSize> + Clone {
1764 let load_width = self.properties().hardware.load_width as usize;
1765 let size_bits = size * 8;
1766 let max = load_width / size_bits;
1767 let max = usize::min(self.properties().hardware.max_vector_size, max);
1768
1769 // If the max is 8, we want to test 1, 2, 4, 8 which is log2(8) + 1.
1770 let num_candidates = max.trailing_zeros() + 1;
1771
1772 (0..num_candidates).map(|i| 2usize.pow(i)).rev()
1773 }
1774
1775 /// Calculates the maximum throughput of the device given the given config (like tensor core with certain sizes and dtypes, or just arithmetic by dtype)
1776 ///
1777 /// # Errors
1778 ///
1779 /// Whatever `probe` reports.
1780 pub fn measure_throughput(
1781 &self,
1782 key: ThroughputKey,
1783 probe: impl FnOnce() -> Result<ThroughputValue, ThroughputError>,
1784 ) -> Result<ThroughputValue, ThroughputError> {
1785 let cache = ThroughputCache::get_for_device(self.name(), self.properties());
1786 let mut throughputs = ThroughputBenchmarker::new(cache);
1787 throughputs.measure(key, probe)
1788 }
1789}
1790
1791fn profile_label(name: &'static str, kernel_id: &KernelId) -> String {
1792 let base = type_name_format(name, TypeNameFormatLevel::Balanced);
1793 kernel_id.entrypoint_name(&base)
1794}