cubecl_runtime/server/base.rs
1use super::Handle;
2use crate::kernel::BufferIOAttr;
3use crate::{
4 client::Client,
5 compiler::CompilationError,
6 config::{CubeClRuntimeConfig, RuntimeConfig, compilation::BoundsCheckMode},
7 dry_run::LaunchMode,
8 id::GraphId,
9 kernel::CubeKernel,
10 logging::ServerLogger,
11 memory_management::{
12 InstallMemoryPoolsError, ManagedMemoryHandle, ManagedMemoryId, MemoryAllocationMode,
13 MemoryConfiguration, MemoryReport, MemoryUsage,
14 },
15 server::{BufferBinding, KernelResource},
16 storage::{ComputeStorage, ManagedResource},
17 tma::{OobFill, TensorMapFormat, TensorMapInterleave, TensorMapPrefetch, TensorMapSwizzle},
18};
19use alloc::boxed::Box;
20use alloc::string::String;
21use alloc::sync::Arc;
22use alloc::vec::Vec;
23use core::{
24 fmt::{Debug, Display},
25 hash::{BuildHasher, Hash},
26};
27use cubecl_common::{
28 bytes::Bytes,
29 device::{self, DeviceId, ServiceId},
30 profile::ProfileDuration,
31};
32use cubecl_environment::backtrace::BackTrace;
33use cubecl_environment::collections::HashSet;
34use cubecl_environment::future::DynFut;
35use cubecl_environment::stream::StreamId;
36use cubecl_environment::sync::RwLock;
37use cubecl_ir::{DeviceProperties, ElemType, TargetProperties, settings::Dim3};
38use cubecl_zspace::{Shape, Strides, metadata::Metadata};
39use derive_more::{Deref, DerefMut, From};
40use foldhash::fast::FixedState;
41use itertools::Itertools;
42use thiserror::Error;
43
44#[derive(Error, Clone)]
45#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
46/// An error during profiling.
47pub enum ProfileError {
48 /// An unknown error happened during profiling
49 #[error(
50 "An unknown error happened during profiling\nCaused by:\n {reason}\nBacktrace:\n{backtrace}"
51 )]
52 Unknown {
53 /// The caused of the error
54 reason: String,
55 /// The captured backtrace.
56 #[cfg_attr(serializable, serde(skip))]
57 backtrace: BackTrace,
58 },
59
60 /// No profiling was registered
61 #[error("No profiling registered\nBacktrace:\n{backtrace}")]
62 NotRegistered {
63 /// The captured backtrace.
64 #[cfg_attr(serializable, serde(skip))]
65 backtrace: BackTrace,
66 },
67
68 /// The profiled window resolved no device timing.
69 ///
70 /// Distinct from a zero duration, which is a measurement that came back
71 /// instant. This is the absence of one: nothing the window enqueued was
72 /// timestamped, so the backend has nothing to report. A caller that only
73 /// wants a number can treat it as zero; a caller comparing candidates must
74 /// not, because an absence that reads as zero is the fastest result there
75 /// is and wins every comparison it enters.
76 #[error("The profiled window resolved no device timing\nBacktrace:\n{backtrace}")]
77 NotMeasured {
78 /// The captured backtrace.
79 #[cfg_attr(serializable, serde(skip))]
80 backtrace: BackTrace,
81 },
82
83 /// A launch error happened during profiling
84 #[error("A launch error happened during profiling\nCaused by:\n {0}")]
85 Launch(#[from] LaunchError),
86
87 /// An execution error happened during profiling
88 #[error("An execution error happened during profiling\nCaused by:\n {0}")]
89 Server(#[from] Box<ServerError>),
90}
91
92/// A failure during a profiling window invalidates the measurement, whatever
93/// the failure was. Every backend answers a launch, write or replay failure
94/// this way, so the conversion lives here rather than five times over.
95impl From<&ServerError> for ProfileError {
96 fn from(error: &ServerError) -> Self {
97 ProfileError::Server(Box::new(error.clone()))
98 }
99}
100
101impl core::fmt::Debug for ProfileError {
102 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
103 f.write_fmt(format_args!("{self}"))
104 }
105}
106
107/// Contains many different types that are useful for server implementations and compute clients.
108pub struct ServerUtilities {
109 /// The time when `profile-tracy` is activated.
110 #[cfg(feature = "profile-tracy")]
111 pub epoch_time: cubecl_environment::time::Instant,
112 /// The GPU client when `profile-tracy` is activated.
113 #[cfg(feature = "profile-tracy")]
114 pub gpu_client: tracy_client::GpuContext,
115 /// The service these utilities belong to: what the handles it allocates
116 /// are stamped with, and what a client compares them against.
117 pub service: ServiceId,
118 /// The runtime name on this device, as logs and cache keys show it:
119 /// `cuda`, `wgpu<spirv>`.
120 pub name: &'static str,
121 /// Information shared between all servers.
122 pub properties: Arc<DeviceProperties>,
123 /// Stable hash of the device properties
124 pub properties_hash: u64,
125 /// What the target the server compiles for guarantees about its own
126 /// instructions — [`Runtime::target_properties`](crate::runtime::Runtime::target_properties)
127 /// resolved once, when the device came up, rather than on every launch.
128 ///
129 /// A kernel keeps a clone of this `Arc` so it can expand itself on the
130 /// device thread without naming a runtime, so building it per launch
131 /// would put a `TargetProperties` construction — and the allocations
132 /// inside it — on the hot path of every already-compiled kernel.
133 pub target_properties: Arc<TargetProperties>,
134 /// The logger based on global cubecl configs.
135 pub logger: Arc<ServerLogger>,
136 /// How to create the allocation.
137 pub layout_policy: Box<dyn MemoryLayoutPolicy>,
138 /// Whether the server can move data to a peer server of the same runtime
139 /// directly, without a round trip through the host: the device transport
140 /// `Client::has_device_transport` reports. Off unless the backend turns it
141 /// on at init.
142 pub server_comm_enabled: bool,
143 /// How to enforce bounds checking on kernels.
144 pub check_mode: BoundsCheckMode,
145 /// A set containing the ids for which the inter-device communication has already been initialized.
146 pub initialized_comms: RwLock<HashSet<CommunicationId>>,
147}
148
149/// Defines how the memory layout is determined.
150pub trait MemoryLayoutPolicy: Send + Sync + 'static {
151 /// Applies the memory layout policy to a list of descriptors.
152 ///
153 /// Returns a vector of `MemoryLayout`, one per descriptor, with layouts that share a
154 /// single `Binding`.
155 fn apply(
156 &self,
157 service: ServiceId,
158 stream_id: StreamId,
159 descriptors: &[MemoryLayoutDescriptor],
160 ) -> (Handle, Vec<MemoryLayout>);
161}
162
163impl core::fmt::Debug for ServerUtilities {
164 fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
165 f.debug_struct("ServerUtilities")
166 .field("properties", &self.properties)
167 .field("name", &self.name)
168 .field("logger", &self.logger)
169 .finish()
170 }
171}
172
173impl ServerUtilities {
174 /// Creates a new server utilities.
175 pub fn new(
176 service: ServiceId,
177 name: &'static str,
178 properties: DeviceProperties,
179 target_properties: TargetProperties,
180 logger: Arc<ServerLogger>,
181 allocator: impl MemoryLayoutPolicy,
182 ) -> Self {
183 // Start a tracy client if needed.
184 #[cfg(feature = "profile-tracy")]
185 let client = tracy_client::Client::start();
186
187 Self {
188 service,
189 name,
190 properties_hash: properties.checksum(),
191 properties: Arc::new(properties),
192 target_properties: Arc::new(target_properties),
193 logger,
194 // Create the GPU client if needed.
195 #[cfg(feature = "profile-tracy")]
196 gpu_client: client
197 .clone()
198 .new_gpu_context(
199 Some(name),
200 // In the future should ask the server what makes sense here. 'Invalid' atm is a generic stand-in (Tracy doesn't have CUDA/RocM atm anyway).
201 tracy_client::GpuContextType::Invalid,
202 0, // Timestamps are manually aligned to this epoch so start at 0.
203 1.0, // Timestamps are manually converted to be nanoseconds so period is 1.
204 )
205 .unwrap(),
206 #[cfg(feature = "profile-tracy")]
207 epoch_time: cubecl_environment::time::Instant::now(),
208 layout_policy: Box::new(allocator),
209 server_comm_enabled: false,
210 check_mode: CubeClRuntimeConfig::get().compilation.check_mode,
211 initialized_comms: RwLock::new(HashSet::default()),
212 }
213 }
214}
215
216/// Kernel Launch Errors.
217#[derive(Error, Clone)]
218#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
219pub enum LaunchError {
220 /// The given kernel can't be compiled.
221 #[error("A compilation error happened during launch\nCaused by:\n {0}")]
222 CompilationError(#[from] CompilationError),
223
224 /// The server is out of memory.
225 #[error(
226 "An out-of-memory error happened during launch\nCaused by:\n {reason}\nBacktrace\n{backtrace}"
227 )]
228 OutOfMemory {
229 /// The caused of the memory error.
230 reason: String,
231 /// The backtrace for this error.
232 #[cfg_attr(serializable, serde(skip))]
233 backtrace: BackTrace,
234 },
235
236 /// Too many resources were requested
237 #[error("Too many resources were requested during launch\n{0}")]
238 TooManyResources(#[from] ResourceLimitError),
239
240 /// Unknown launch error.
241 #[error(
242 "An unknown error happened during launch\nCaused by:\n {reason}\nBacktrace\n{backtrace}"
243 )]
244 Unknown {
245 /// The caused of the unknown error.
246 reason: String,
247 /// The backtrace for this error.
248 #[cfg_attr(serializable, serde(skip))]
249 backtrace: BackTrace,
250 },
251}
252
253/// Resource limit errors.
254#[derive(Error, Clone)]
255#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
256pub enum ResourceLimitError {
257 /// Shared memory exceeds maximum
258 #[error(
259 "Too much shared memory requested.\nRequested {requested} bytes, maximum {max} bytes available.\nBacktrace\n{backtrace}"
260 )]
261 SharedMemory {
262 /// Value requested
263 requested: usize,
264 /// Maximum value
265 max: usize,
266 /// The backtrace for this error.
267 #[cfg_attr(serializable, serde(skip))]
268 backtrace: BackTrace,
269 },
270 /// Total units exceeds maximum
271 #[error(
272 "Total unit count exceeds maximum.\nRequested {requested} units, max units is {max}.\nBacktrace\n{backtrace}"
273 )]
274 Units {
275 /// Requested value
276 requested: u32,
277 /// Maximum value
278 max: u32,
279 /// The backtrace for this error.
280 #[cfg_attr(serializable, serde(skip))]
281 backtrace: BackTrace,
282 },
283 /// `CubeDim` exceeds maximum
284 #[error(
285 "Cube dim exceeds maximum bounds.\nRequested {requested:?}, max is {max:?}.\nBacktrace\n{backtrace}"
286 )]
287 CubeDim {
288 /// Requested value
289 requested: (u32, u32, u32),
290 /// Maximum value
291 max: (u32, u32, u32),
292 /// The backtrace for this error.
293 #[cfg_attr(serializable, serde(skip))]
294 backtrace: BackTrace,
295 },
296}
297
298impl core::fmt::Debug for LaunchError {
299 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
300 f.write_fmt(format_args!("{self}"))
301 }
302}
303
304impl core::fmt::Debug for ResourceLimitError {
305 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
306 f.write_fmt(format_args!("{self}"))
307 }
308}
309
310/// A collective operation between the devices of one runtime.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
313pub enum Collective {
314 /// Setting up the communication between a group of devices.
315 CommInit,
316 /// Reducing a buffer across a group of devices.
317 AllReduce,
318 /// Sending a buffer to a peer device.
319 Send,
320 /// Receiving a buffer from a peer device.
321 Recv,
322 /// Waiting for queued collectives to finish.
323 SyncCollective,
324}
325
326impl Display for Collective {
327 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
328 f.write_str(match self {
329 Self::CommInit => "comm_init",
330 Self::AllReduce => "all_reduce",
331 Self::Send => "send",
332 Self::Recv => "recv",
333 Self::SyncCollective => "sync_collective",
334 })
335 }
336}
337
338/// Error that can happen asynchronously while executing registered kernels.
339#[derive(Error, Clone)]
340#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
341pub enum ServerError {
342 /// A runtime validation error
343 #[error(
344 "A validation error happened during execution\nCaused by:\n {message}\nBacktrace:\n{backtrace}"
345 )]
346 Validation {
347 /// The details of the validation error.
348 message: String,
349 /// The backtrace for this error.
350 #[cfg_attr(serializable, serde(skip))]
351 backtrace: BackTrace,
352 },
353
354 /// A generic runtime error.
355 #[error("An error happened during execution\nCaused by:\n {reason}\nBacktrace:\n{backtrace}")]
356 Generic {
357 /// The details of the generic error.
358 reason: String,
359 /// The backtrace for this error.
360 #[cfg_attr(serializable, serde(skip))]
361 backtrace: BackTrace,
362 },
363
364 /// The runtime has no transport between its devices, so the collective was
365 /// not run. Its own variant rather than a `Generic` reason, so a caller can
366 /// tell a runtime that cannot do this apart from a collective that failed.
367 #[error(
368 "{operation} needs a transport between devices, and this runtime has none\nBacktrace:\n{backtrace}"
369 )]
370 NoDeviceTransport {
371 /// The collective that was asked for.
372 operation: Collective,
373 /// The backtrace for this error.
374 #[cfg_attr(serializable, serde(skip))]
375 backtrace: BackTrace,
376 },
377
378 /// A handle from one service was handed to a client of another: memory
379 /// coordinates mean nothing there, so nothing was run.
380 #[error("A handle of {handle} was used on {client}\nBacktrace:\n{backtrace}")]
381 ForeignHandle {
382 /// The service whose memory the handle addresses.
383 handle: String,
384 /// The service the client reaches.
385 client: String,
386 /// The backtrace for this error.
387 #[cfg_attr(serializable, serde(skip))]
388 backtrace: BackTrace,
389 },
390
391 /// A caller named a server type the client does not reach. The client is
392 /// erased over its server, so the type it is asked for is checked against
393 /// the one it was built from, and nothing was run.
394 #[error("The client reaches {client}, not a {requested}\nBacktrace:\n{backtrace}")]
395 ServiceMismatch {
396 /// The service the client reaches.
397 client: String,
398 /// The server type the caller asked for.
399 requested: String,
400 /// The backtrace for this error.
401 #[cfg_attr(serializable, serde(skip))]
402 backtrace: BackTrace,
403 },
404
405 /// A launch error happened
406 #[error("A launch error happened\nCaused by:\n {0}")]
407 Launch(#[from] LaunchError),
408
409 /// An IO error happened
410 #[error("An IO error happened\nCaused by:\n {0}")]
411 Io(#[from] IoError),
412
413 /// The work writing this buffer was torn down before it could say what
414 /// went wrong: its write scope never reached the exit that names the real
415 /// failure, which a panic mid-launch explains.
416 ///
417 /// This is the provisional error every write scope enters with, so it
418 /// carries no payload and captures no backtrace — a launch that succeeds
419 /// mints one and drops it again, and paying a `String` and a stack walk
420 /// per launch for the message nobody normally reads is the whole reason
421 /// it is a variant rather than a [`Generic`](Self::Generic).
422 #[error(
423 "The work writing this buffer was torn down before it could say what went wrong: its \
424 write scope never reached the exit that names the real failure, which a panic \
425 mid-launch explains"
426 )]
427 TornDown,
428
429 /// The bytes asked about were never written: the work that was going to
430 /// write them failed, or was skipped downstream of a failure. `chain`
431 /// walks from the buffer asked about back toward the root, newest skip
432 /// first, and `root` is the failure that started it.
433 #[error(
434 "The bytes were never written (failure #{failure}, still claiming {claimed} buffer(s))\n{}Caused by:\n {root}\nAsked at:\n{backtrace}",
435 chain.iter().map(|hop| alloc::format!(" {hop}\n")).collect::<String>()
436 )]
437 Unwritten {
438 /// The failure's id in the device's error store, as printed by every
439 /// other read that trips over the same failure.
440 failure: u64,
441 /// How many buffers the failure still claims.
442 claimed: u32,
443 /// The skip chain from the buffer asked about back toward the root.
444 chain: Vec<String>,
445 /// The failure that started it, backtrace included.
446 root: Box<ServerError>,
447 /// Where the question was asked, so the lazy report and the read that
448 /// tripped over it can be tied together.
449 #[cfg_attr(serializable, serde(skip))]
450 backtrace: BackTrace,
451 },
452
453 /// The work did not run, because an input it needed carried a failure.
454 ///
455 /// The report is on the buffers: the work's outputs claim the failure its
456 /// inputs did, so a read of one of them names the root cause and the path
457 /// back to it. This variant says only *that* the caller's work was
458 /// skipped, which is why it carries no payload — the failure the inputs
459 /// held is not the caller's to receive here, and minting a formatted
460 /// message per skip would cost the loop that skips on every iteration.
461 #[error(
462 "The work was skipped: an input carried a failure, and the work's outputs claim it now \
463 — a read of one of them names the root cause"
464 )]
465 Skipped,
466
467 /// More than one thing went wrong at once, and the caller is owed all of
468 /// them: a read naming buffers that several distinct failures claim, or a
469 /// capture that was both doomed and abandoned.
470 #[error("Several failures at once\nCaused by:\n {}", errors.iter().join("\n"))]
471 Several {
472 /// The failures, in the order they were found.
473 errors: Vec<Self>,
474 /// The backtrace for this error.
475 #[cfg_attr(serializable, serde(skip))]
476 backtrace: BackTrace,
477 },
478}
479
480impl Debug for ServerError {
481 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
482 write!(f, "{self}")
483 }
484}
485
486impl ServerError {
487 /// The error every collective returns on a runtime with no transport between devices.
488 pub fn no_device_transport(operation: Collective) -> Self {
489 Self::NoDeviceTransport {
490 operation,
491 backtrace: BackTrace::capture(),
492 }
493 }
494
495 /// Whether this is the kernel being refused before it ran, rather than
496 /// something going wrong while running it.
497 ///
498 /// The distinction a test harness or an autotuner needs: a kernel a
499 /// backend cannot build at this configuration is a candidate to drop or a
500 /// case to skip, while a fault, an out-of-memory or an IO failure is a
501 /// defect that has to be reported. Answering it by reading the message is
502 /// how a harness ends up accepting the second as the first.
503 ///
504 /// Walks [`Several`](Self::Several) and [`Unwritten`](Self::Unwritten) to
505 /// the roots, because a read of an unwritten buffer reports the failure
506 /// that stopped its writer and that is where the distinction lives. A
507 /// group answers yes only when every root does: one real failure among
508 /// refusals is still a real failure, and an empty group refuses nothing.
509 pub fn is_refusal(&self) -> bool {
510 match self {
511 Self::Launch(LaunchError::CompilationError(_) | LaunchError::TooManyResources(_)) => {
512 true
513 }
514 Self::Unwritten { root, .. } => root.is_refusal(),
515 Self::Several { errors, .. } => {
516 !errors.is_empty() && errors.iter().all(Self::is_refusal)
517 }
518 _ => false,
519 }
520 }
521
522 /// A graph-capture call the stream's lifecycle does not allow — a
523 /// `begin_capture` without `graph_prepare`, a second overlapping capture, a
524 /// replay of an unknown graph, or an operation a capture window cannot
525 /// record. `reason` names the call and what was wrong with it.
526 pub fn graph_state(reason: impl Into<String>) -> Self {
527 Self::Generic {
528 reason: reason.into(),
529 backtrace: BackTrace::capture(),
530 }
531 }
532
533 /// The error the default (unsupported) graph-capture methods return, for a
534 /// backend that has no graph support at all.
535 pub fn graph_capture_unsupported() -> Self {
536 Self::graph_state("graph capture is not supported by this backend")
537 }
538}
539
540/// The compute server is responsible for handling resources and computations over resources.
541///
542/// Everything in the server is mutable, therefore it should be solely accessed through the
543/// [`Client`] for thread safety.
544pub trait Server:
545 core::any::Any + Send + core::fmt::Debug + ServerCommunication + device::DeviceService + 'static
546{
547 /// Initializes [memory](ManagedMemoryHandle) on the given [stream](StreamId) with the given size.
548 fn initialize_memory(&mut self, memory: ManagedMemoryHandle, size: u64, stream_id: StreamId);
549
550 /// Reserves N [Bytes] of the provided sizes to be used as staging to load data.
551 fn staging(
552 &mut self,
553 _sizes: &[usize],
554 _stream_id: StreamId,
555 ) -> Result<Vec<Bytes>, ServerError> {
556 Err(IoError::UnsupportedIoOperation {
557 backtrace: BackTrace::capture(),
558 }
559 .into())
560 }
561
562 /// Retrieve the server logger.
563 fn logger(&self) -> Arc<ServerLogger>;
564
565 /// Retrieve the server utilities.
566 fn utilities(&self) -> Arc<ServerUtilities>;
567
568 /// Given bindings, returns the owned resources as bytes.
569 ///
570 /// # Errors
571 ///
572 /// [`ServerError::Several`] when the work that was supposed to
573 /// write one of these buffers failed, whichever stream it ran on — copying
574 /// bytes out would hand back whatever was in memory before. Every
575 /// implementation asks
576 /// `FailureStore::ensure_written` (in `cubecl-server`) before it copies
577 /// anything.
578 fn read(
579 &mut self,
580 descriptors: Vec<CopyDescriptor>,
581 stream_id: StreamId,
582 ) -> DynFut<Result<Vec<Bytes>, ServerError>>;
583
584 /// Writes the specified bytes into the buffers given
585 fn write(&mut self, descriptors: Vec<(CopyDescriptor, Bytes)>, stream_id: StreamId);
586
587 /// Wait for the completion of every task in the server, then answer for
588 /// `handles`: the barrier first, so device faults count, and then the
589 /// claim check a read would have made — a read without the read.
590 ///
591 /// An empty `handles` is the plain barrier plus the device fault, which
592 /// is the only failure left that no buffer can report.
593 fn sync(
594 &mut self,
595 handles: Vec<BufferBinding>,
596 stream_id: StreamId,
597 ) -> DynFut<Result<(), ServerError>>;
598
599 /// Whether the bytes the handles name can be trusted, right now and with
600 /// no barrier: the claim check a read makes, without the read. Instant —
601 /// enqueue-time failures only. A device fault needs [`sync`](Self::sync),
602 /// which drains first.
603 fn check(
604 &mut self,
605 handles: Vec<BufferBinding>,
606 stream_id: StreamId,
607 ) -> Result<(), ServerError>;
608
609 /// Executes the `kernel` over the given memory `handles`.
610 ///
611 /// Kernels have mutable access to every resource they are given
612 /// and are responsible of determining which should be read or written.
613 ///
614 /// `launch_mode` says whether the kernel actually runs. On
615 /// [`LaunchMode::Skip`] the server must still do everything a first launch
616 /// does short of dispatching — expand, compile, validate, fill its caches —
617 /// and then drop the launch; skipping the compilation instead would defeat
618 /// the whole point of a [dry run](crate::dry_run).
619 ///
620 /// # Safety
621 ///
622 /// When executing with mode [`ExecutionMode::Unchecked`], out-of-bound reads and writes can happen.
623 unsafe fn launch(
624 &mut self,
625 kernel: Box<dyn CubeKernel>,
626 count: CubeCount,
627 bindings: KernelArguments,
628 stream_id: StreamId,
629 launch_mode: LaunchMode,
630 );
631
632 /// Flush all outstanding tasks in the server.
633 ///
634 /// # Errors
635 ///
636 /// The device fault, when the context itself is broken — a launch failure
637 /// is not the flush's to report: it lives on the buffers the launch left
638 /// unwritten, and surfaces on any read, sync or check of them.
639 fn flush(&mut self, stream_id: StreamId) -> Result<(), ServerError>;
640
641 /// Prepare `stream_id` for an upcoming graph capture: route allocations
642 /// into a stable pool and snapshot it, so every buffer allocated between
643 /// here and [`end_capture`](Server::end_capture) can be pinned for
644 /// the graph's lifetime. Call this **before** the warmup run so the capture
645 /// window reuses the slices warmup left in the pool rather than allocating
646 /// its own — which a hardware-graph backend cannot do at all (a device
647 /// malloc inside the capture is illegal there), and which on any backend
648 /// would grow the memory a graph pins beyond what it replays against.
649 ///
650 /// Prefer having kernels already **autotuned before** this call: any
651 /// transient benchmark buffers autotune allocates while the window is armed
652 /// are forced into the persistent pool and pinned to the graph, so a graph
653 /// captured over a cold autotune cache retains more device memory than it
654 /// replays against. Warm the autotune cache first, then `graph_prepare` and
655 /// warm up only to populate the pool.
656 ///
657 /// A no-op by default (harmless on backends without graph support); a
658 /// backend with graph support enables its persistent pool + capture
659 /// recording.
660 fn graph_prepare(&mut self, stream_id: StreamId) -> Result<(), ServerError> {
661 let _ = stream_id;
662 Ok(())
663 }
664
665 /// Begin recording the launches issued on `stream_id` into a graph instead
666 /// of executing them, so the sequence can later be
667 /// [replayed](Server::replay) without paying the launch path again.
668 /// Call [`graph_prepare`](Server::graph_prepare) and warm up first.
669 ///
670 /// Between this call and [`end_capture`](Server::end_capture) the
671 /// stream must not synchronize — a read, a sync or a profile either aborts
672 /// the capture or is refused — and should not allocate fresh device memory,
673 /// which `graph_prepare` plus a warmup run is what avoids. Whether an
674 /// operation the window cannot record fails the call or fails
675 /// `end_capture`, and whether a mid-window allocation is fatal, is the
676 /// backend's to say; see `StreamCapture` in `cubecl-server`.
677 ///
678 /// The default is unsupported. Two shapes of backend override it: a
679 /// **hardware graph** (CUDA, HIP), where the driver records a replayable
680 /// graph object, and a **software graph** (wgpu), where the runtime records
681 /// fully-resolved dispatches and re-encodes them on replay.
682 fn begin_capture(&mut self, stream_id: StreamId) -> Result<(), ServerError> {
683 let _ = stream_id;
684 Err(ServerError::graph_capture_unsupported())
685 }
686
687 /// Stop recording (see [`begin_capture`](Server::begin_capture)),
688 /// store the captured graph in the backend's registry, and return its
689 /// [`GraphId`], ready to [replay](Server::replay).
690 fn end_capture(&mut self, stream_id: StreamId) -> Result<GraphId, ServerError> {
691 let _ = stream_id;
692 Err(ServerError::graph_capture_unsupported())
693 }
694
695 /// Replay the graph identified by `graph` on `stream_id`, re-running the
696 /// whole recorded launch sequence against its original buffers. A hardware
697 /// graph replays as a single dispatch; a software graph re-encodes the
698 /// recorded dispatches, which is still far cheaper than the launch path but
699 /// stays O(n) in recorded launches.
700 ///
701 /// The call enqueues the dispatch and returns without waiting for the
702 /// device; what it reports is the enqueue — an unknown or destroyed
703 /// graph, a refusal — since a caller replaying a graph is standing right
704 /// there. A failure also leaves the graph's write set carrying it, so a
705 /// read of those buffers fails until a replay lands. Unsupported by
706 /// default: a [`GraphId`] can only come from
707 /// [`end_capture`](Server::end_capture).
708 fn replay(&mut self, graph: GraphId, stream_id: StreamId) -> Result<(), ServerError> {
709 let _ = (graph, stream_id);
710 Err(ServerError::graph_capture_unsupported())
711 }
712
713 /// Release the graph identified by `graph`, destroying whatever it recorded
714 /// and unpinning the buffers it retained. Replay returns at enqueue time,
715 /// so the backend must guarantee no in-flight replay can still read those
716 /// buffers once they return to the pool — by syncing `stream_id` where
717 /// nothing weaker will do (CUDA, HIP), or by relying on the queue ordering
718 /// that already places a submitted replay ahead of any later write (wgpu).
719 /// A no-op by default and for an unknown id.
720 fn graph_destroy(&mut self, graph: GraphId, stream_id: StreamId) {
721 let _ = (graph, stream_id);
722 }
723
724 /// Memory usage of the given stream.
725 fn memory_usage(&mut self, stream_id: StreamId) -> MemoryUsage;
726
727 /// Structured per-pool report of the given stream's **main GPU** memory:
728 /// each pool's shape, usage, and high-water marks, in allocation-routing
729 /// order. The read side of a measured memory plan — see
730 /// `MemoryManagement::memory_report` in `cubecl-server`.
731 fn memory_report(&mut self, stream_id: StreamId) -> MemoryReport;
732
733 /// Stream ids the client should iterate to aggregate across the device.
734 ///
735 /// Default is just the calling stream, which is correct for
736 /// non-multi-stream backends; multi-stream backends override to
737 /// return one id per initialized stream pool slot.
738 fn stream_ids(&self) -> Vec<StreamId> {
739 Vec::from([StreamId::current()])
740 }
741
742 /// Ask the server to release memory that it can release.
743 fn memory_cleanup(&mut self, stream_id: StreamId);
744
745 /// Install a new dynamic-pool layout for the device's **main GPU** memory.
746 ///
747 /// The calling stream's pools are rebuilt in place (see
748 /// `MemoryManagement::install_pools` in `cubecl-server`
749 /// — a rebuild only happens when nothing is live in them), and the layout
750 /// becomes the one every stream created afterwards is built with. Pool
751 /// layouts are a purely programmatic, runtime setting — there is no
752 /// config-file pathway — so callers size them per workload (e.g. per model,
753 /// just before loading it).
754 ///
755 /// # Errors
756 ///
757 /// [`PoolsInUse`](InstallMemoryPoolsError::PoolsInUse) when the calling
758 /// stream kept its old layout because something was still live in its
759 /// pools — e.g. a garbage-collection task that has not released its
760 /// cross-stream pins yet, which can lag behind an explicit
761 /// [`memory_cleanup`](Self::memory_cleanup). The layout still applies to
762 /// streams created afterwards; retry to rebuild the calling stream too.
763 ///
764 /// [`Unsupported`](InstallMemoryPoolsError::Unsupported) from servers
765 /// without configurable pools, which is the default implementation.
766 fn install_memory_pools(
767 &mut self,
768 config: MemoryConfiguration,
769 stream_id: StreamId,
770 ) -> Result<(), InstallMemoryPoolsError> {
771 let _ = (config, stream_id);
772 Err(InstallMemoryPoolsError::Unsupported)
773 }
774
775 /// Enable collecting timestamps.
776 fn start_profile(&mut self, stream_id: StreamId) -> Result<ProfilingToken, ServerError>;
777
778 /// Disable collecting timestamps.
779 fn end_profile(
780 &mut self,
781 stream_id: StreamId,
782 token: ProfilingToken,
783 ) -> Result<ProfileDuration, ProfileError>;
784
785 /// Drop the window `token` opened without measuring it, for a caller that
786 /// will never close it with [`end_profile`](Self::end_profile).
787 ///
788 /// An open window is not free: depending on the backend it retains
789 /// command buffers, keeps timestamp writes on, or holds a device event.
790 ///
791 /// Every backend overrides this, and should: the default closes the window
792 /// and throws the measurement away, which is the most expensive way to be
793 /// rid of it — [`end_profile`](Self::end_profile) is where the syncing and
794 /// flushing live, and this is the one call that needs none of it.
795 fn abandon_profile(&mut self, stream_id: StreamId, token: ProfilingToken) {
796 let _ = self.end_profile(stream_id, token);
797 }
798
799 /// Update the memory mode of allocation in the server.
800 fn allocation_mode(&mut self, mode: MemoryAllocationMode, stream_id: StreamId);
801}
802
803/// The storage a server allocates from, and the native resources it hands
804/// out. Kept off [`Server`] so that trait is object-safe: a resource's
805/// type is the backend's own, and only a caller that names the backend can
806/// receive one.
807pub trait ServerStorage: Server {
808 /// The [storage](ComputeStorage) type defines how data is stored and accessed.
809 type Storage: ComputeStorage;
810 /// Given a resource handle, returns the storage resource.
811 ///
812 /// The same claim check a read makes guards this too: a buffer a failed
813 /// launch never filled reports the failure rather than handing back a
814 /// pointer to whatever was there before. It costs a field read on a slice
815 /// the resolution walks anyway.
816 fn get_resource(
817 &mut self,
818 binding: BufferBinding,
819 stream_id: StreamId,
820 ) -> Result<ManagedResource<<Self::Storage as ComputeStorage>::Resource>, ServerError>;
821}
822
823/// An ID unique to any unordered combination of devices.
824#[derive(Clone, Debug, Hash, Eq, PartialEq)]
825pub struct CommunicationId {
826 /// The ID as a `String`.
827 pub id: u64,
828}
829
830impl From<Vec<DeviceId>> for CommunicationId {
831 fn from(mut value: Vec<DeviceId>) -> Self {
832 // Make sure that device ids are sorted so that any combination of the same devices uses the same communicator.
833 value.sort();
834 CommunicationId {
835 id: FixedState::default().hash_one(value),
836 }
837 }
838}
839
840/// Different reduce operations.
841pub enum ReduceOperation {
842 /// Sum.
843 Sum,
844 /// Mean.
845 Mean,
846}
847
848/// Defines functions for optimized data transfer between servers, supporting custom communication
849/// mechanisms such as peer-to-peer communication or specialized implementations.
850///
851/// # Inside the tainted-buffer rules
852///
853/// A collective reads a source buffer and produces a destination one, and owes
854/// the same two answers the rest of the server gives: ask whether the source
855/// carries a failure on the way in (as [`read`](Server::read) does
856/// through
857/// `FailureStore::ensure_written` in `cubecl-server`),
858/// and taint the destination on the way out when the operation fails (as a
859/// failed [`launch`](Server::launch) does). Skipping either lets a
860/// collective reduce stale bytes across every device in the group, or leave a
861/// destination that reads back clean when nothing wrote it.
862///
863/// The default methods are for a runtime with no transport between its devices:
864/// each returns [`ServerError::NoDeviceTransport`] before touching any buffer, so
865/// there is no destination to taint.
866pub trait ServerCommunication {
867 /// Ensure that all queued collective operations have been executed.
868 ///
869 /// # Arguments
870 ///
871 /// * `stream_id` - The [`StreamId`] of the stream waiting for the sync.
872 ///
873 /// # Returns
874 ///
875 /// Returns a `Result` containing an `ServerError` if the operation fails.
876 ///
877 /// # Errors
878 ///
879 /// The default returns [`ServerError::NoDeviceTransport`].
880 #[allow(unused_variables)]
881 fn sync_collective(&mut self, stream_id: StreamId) -> Result<(), ServerError> {
882 Err(ServerError::no_device_transport(Collective::SyncCollective))
883 }
884
885 /// Initialize the communication between the devices in `device_ids`.
886 ///
887 /// # Arguments
888 ///
889 /// * `device_ids` - The IDs of the devices that need communication.
890 ///
891 /// # Returns
892 ///
893 /// Returns a `Result` containing an `ServerError` if the operation fails.
894 ///
895 /// # Errors
896 ///
897 /// The default returns [`ServerError::NoDeviceTransport`].
898 #[allow(unused_variables)]
899 fn comm_init(&mut self, device_ids: Vec<DeviceId>) -> Result<(), ServerError> {
900 Err(ServerError::no_device_transport(Collective::CommInit))
901 }
902
903 /// Performs an `all_reduce` operation on the input data and writes it to the output buffer.
904 /// see <https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/usage/collectives.html#allreduce>
905 ///
906 /// # Arguments
907 ///
908 /// * `src` - The data to be reduced.
909 /// * `dst` - Where to write the result.
910 /// * `dtype` - The element type of the data being reduced
911 /// * `stream_id` - The data's stream id.
912 /// * `op` - The reduce's aggregation operation e.g. mean, sum, etc.
913 /// * `device_ids` - The list of device ids from which to `all_reduce`.
914 ///
915 /// # Returns
916 ///
917 /// Returns a `Result` containing an `ServerError` if the operation fails.
918 ///
919 /// # Errors
920 ///
921 /// The default returns [`ServerError::NoDeviceTransport`].
922 #[allow(unused_variables)]
923 fn all_reduce(
924 &mut self,
925 src: BufferBinding,
926 dst: BufferBinding,
927 dtype: ElemType,
928 stream_id: StreamId,
929 op: ReduceOperation,
930 device_ids: Vec<DeviceId>,
931 ) -> Result<(), ServerError> {
932 Err(ServerError::no_device_transport(Collective::AllReduce))
933 }
934
935 /// Sends data from this server to a destination server.
936 ///
937 /// # Arguments
938 ///
939 /// * `desc` - A descriptor specifying the data to be sent, including shape, strides, and binding.
940 /// * `dtype` - The element type of the data being sent.
941 /// * `stream_id` - The stream ID associated with the server's operation.
942 /// * `device_id_dst` - ID of the device receiving the data.
943 ///
944 /// # Returns
945 ///
946 /// Returns a `Result` containing an `ServerError` if the operation fails.
947 ///
948 /// # Known limitation
949 ///
950 /// Send and recv are posted fire-and-forget on two devices and block for
951 /// each other, so a send that refuses — a source whose writer failed,
952 /// above all — leaves the peer's already-posted recv waiting on its
953 /// communication stream with no way to recall it from here. The refusal
954 /// is still right: completing the send would launder stale bytes onto a
955 /// handle that carries no claim on the other device. Cross-device
956 /// failure propagation needs a design pass of its own.
957 ///
958 /// # Errors
959 ///
960 /// The default returns [`ServerError::NoDeviceTransport`].
961 #[allow(unused_variables)]
962 fn send(
963 &mut self,
964 desc: CopyDescriptor,
965 dtype: ElemType,
966 stream_id: StreamId,
967 device_id_dst: DeviceId,
968 ) -> Result<(), ServerError> {
969 Err(ServerError::no_device_transport(Collective::Send))
970 }
971
972 /// Receive data from another server.
973 ///
974 /// # Arguments
975 ///
976 /// * `handle` - The handle in which the received data is written.
977 /// * `dtype` - The element type of the data being sent.
978 /// * `stream_id` - The stream ID associated with the server's operation.
979 /// * `device_id_src` - ID of the device sending the data.
980 ///
981 /// # Returns
982 ///
983 /// Returns a `Result` containing an `ServerError` if the operation fails.
984 ///
985 /// # Errors
986 ///
987 /// The default returns [`ServerError::NoDeviceTransport`].
988 #[allow(unused_variables)]
989 fn recv(
990 &mut self,
991 handle: Handle,
992 dtype: ElemType,
993 stream_id: StreamId,
994 device_id_src: DeviceId,
995 ) -> Result<(), ServerError> {
996 Err(ServerError::no_device_transport(Collective::Recv))
997 }
998}
999
1000#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
1001/// Profiling identification so that the server can support recursive and overlapping profilings.
1002pub struct ProfilingToken {
1003 /// The token value.
1004 pub id: u64,
1005}
1006
1007/// Type of allocation, either contiguous or optimized (row-aligned when possible)
1008#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
1009pub enum MemoryLayoutStrategy {
1010 /// Contiguous layout, with no padding
1011 Contiguous,
1012 /// Optimized for access speed. In practice this means row-aligned with padding for runtimes
1013 /// that support it.
1014 Optimized,
1015}
1016
1017/// Descriptor for a new tensor allocation
1018#[derive(new, Debug, Clone)]
1019pub struct MemoryLayoutDescriptor {
1020 /// Strategy used to create the memory layout.
1021 pub strategy: MemoryLayoutStrategy,
1022 /// Shape of the tensor
1023 pub shape: Shape,
1024 /// Size of each element in the tensor (used for conversion of shape to bytes)
1025 pub elem_size: usize,
1026}
1027
1028impl MemoryLayoutDescriptor {
1029 /// Create an optimized allocation descriptor
1030 pub fn optimized(shape: Shape, elem_size: usize) -> Self {
1031 MemoryLayoutDescriptor::new(MemoryLayoutStrategy::Optimized, shape, elem_size)
1032 }
1033
1034 /// Create a contiguous allocation descriptor
1035 pub fn contiguous(shape: Shape, elem_size: usize) -> Self {
1036 MemoryLayoutDescriptor::new(MemoryLayoutStrategy::Contiguous, shape, elem_size)
1037 }
1038}
1039
1040/// An allocation with associated strides. Strides depend on tensor layout.
1041#[derive(Debug, Clone)]
1042pub struct MemoryLayout {
1043 /// The handle for the memory resource
1044 pub memory: Handle,
1045 /// TODO: `Strides` should become `Layout`.
1046 ///
1047 /// The strides of the tensor
1048 pub strides: Strides,
1049}
1050
1051impl MemoryLayout {
1052 /// Create a new memory layout.
1053 pub fn new(handle: Handle, strides: impl Into<Strides>) -> Self {
1054 MemoryLayout {
1055 memory: handle,
1056 strides: strides.into(),
1057 }
1058 }
1059}
1060
1061/// A reason for an error.
1062#[derive(Default, Clone)]
1063pub struct Reason {
1064 inner: ReasonInner,
1065}
1066
1067#[cfg(serializable)]
1068mod _reason_serde {
1069 use super::*;
1070
1071 use alloc::string::ToString;
1072 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1073
1074 impl Serialize for Reason {
1075 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1076 where
1077 S: Serializer,
1078 {
1079 // Use the Display implementation (via to_string) to flatten the enum
1080 serializer.serialize_str(&self.to_string())
1081 }
1082 }
1083
1084 impl<'de> Deserialize<'de> for Reason {
1085 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1086 where
1087 D: Deserializer<'de>,
1088 {
1089 // Deserialize into a standard String first
1090 let s = String::deserialize(deserializer)?;
1091
1092 // Wrap it in the Dynamic variant since we can't safely
1093 // reconstruct a 'static str from a runtime string.
1094 Ok(Reason {
1095 inner: ReasonInner::Dynamic(Arc::new(s)),
1096 })
1097 }
1098 }
1099}
1100
1101#[derive(Default, Clone)]
1102enum ReasonInner {
1103 Static(&'static str),
1104 Dynamic(Arc<String>),
1105 #[default]
1106 NotProvided,
1107}
1108
1109impl core::fmt::Display for Reason {
1110 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1111 match &self.inner {
1112 ReasonInner::Static(content) => f.write_str(content),
1113 ReasonInner::Dynamic(content) => f.write_str(content),
1114 ReasonInner::NotProvided => f.write_str("No reason provided for the error"),
1115 }
1116 }
1117}
1118
1119impl core::fmt::Debug for Reason {
1120 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1121 core::fmt::Display::fmt(&self, f)
1122 }
1123}
1124
1125impl From<&'static str> for Reason {
1126 fn from(value: &'static str) -> Self {
1127 Self {
1128 inner: ReasonInner::Static(value),
1129 }
1130 }
1131}
1132
1133impl From<String> for Reason {
1134 fn from(value: String) -> Self {
1135 Self {
1136 inner: ReasonInner::Dynamic(Arc::new(value)),
1137 }
1138 }
1139}
1140
1141/// Error returned from `create`/`read`/`write` functions. Due to async execution not all errors
1142/// are able to be caught, so some IO errors will still panic.
1143#[derive(Error, Clone)]
1144#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
1145pub enum IoError {
1146 /// Buffer size exceeds the max available
1147 #[error("can't allocate buffer of size: {size}\n{backtrace}")]
1148 BufferTooBig {
1149 /// The size of the buffer in bytes.
1150 size: u64,
1151 /// The captured backtrace.
1152 #[cfg_attr(serializable, serde(skip))]
1153 backtrace: BackTrace,
1154 },
1155
1156 /// The device had no memory left for this allocation.
1157 ///
1158 /// Unlike [`IoError::BufferTooBig`] (the allocation can *never* fit), this
1159 /// describes the device at one moment: pool pages whose slices have all
1160 /// been dropped are still resident, and the frees that would release them
1161 /// may not have reached the driver yet. Reclaiming and retrying is a
1162 /// reasonable response, which is why a storage backend must not report a
1163 /// driver out-of-memory as `BufferTooBig`: that tells every caller the
1164 /// allocation is hopeless when it is merely untimely.
1165 #[error("out of device memory allocating {size} bytes\n{backtrace}")]
1166 OutOfMemory {
1167 /// The size of the failed allocation in bytes.
1168 size: u64,
1169 /// The captured backtrace.
1170 #[cfg_attr(serializable, serde(skip))]
1171 backtrace: BackTrace,
1172 },
1173
1174 /// A memory pool with a fixed capacity cap is exhausted.
1175 ///
1176 /// Unlike [`IoError::BufferTooBig`] (the allocation can *never* fit), this
1177 /// means the working set exceeded the configured budget. Server execution
1178 /// paths treat it as fatal — the budget is a hard contract, so failing
1179 /// early beats silently growing — but callers that manage their own
1180 /// working set may free pool memory and retry.
1181 #[error(
1182 "memory pool capacity exceeded: failed to reserve {size} bytes, pool is capped at {capacity} bytes ({in_use} bytes in use)\n{backtrace}"
1183 )]
1184 PoolCapacityExceeded {
1185 /// The size of the failed reservation in bytes.
1186 size: u64,
1187 /// The configured pool capacity in bytes (whole pages).
1188 capacity: u64,
1189 /// Bytes currently in use in the pool.
1190 in_use: u64,
1191 /// The captured backtrace.
1192 #[cfg_attr(serializable, serde(skip))]
1193 backtrace: BackTrace,
1194 },
1195
1196 /// Strides aren't supported for this copy operation on this runtime
1197 #[error("the provided strides are not supported for this operation\n{backtrace}")]
1198 UnsupportedStrides {
1199 /// The backtrace.
1200 #[cfg_attr(serializable, serde(skip))]
1201 backtrace: BackTrace,
1202 },
1203
1204 /// Memory wasn't found in the memory pool
1205 #[error("couldn't find resource for that handle: {reason}\n{backtrace}")]
1206 NotFound {
1207 /// The backtrace.
1208 #[cfg_attr(serializable, serde(skip))]
1209 backtrace: BackTrace,
1210 /// The reason the handle is invalid.
1211 reason: Reason,
1212 },
1213
1214 /// The storage backend holds no allocation for a handle's storage id.
1215 ///
1216 /// One layer below [`IoError::NotFound`]: there the memory manager could
1217 /// not route a binding to a slice, here the routing succeeded and the
1218 /// allocation the slice names is gone. A handle outliving its page, or a
1219 /// storage id a deallocation retired, reaches the storage this way.
1220 #[error("the storage holds no allocation for that handle: {reason}\n{backtrace}")]
1221 StorageHandleNotFound {
1222 /// Which id was looked up, and in which storage.
1223 reason: Reason,
1224 /// The backtrace.
1225 #[cfg_attr(serializable, serde(skip))]
1226 backtrace: BackTrace,
1227 },
1228
1229 /// An allocation carved lazily under a [`DryRun`](crate::dry_run::DryRun)
1230 /// could not be given real device backing when it was finally resolved.
1231 ///
1232 /// Distinct from the same failure at reservation time, and the distinction
1233 /// is what a caller acts on: the memory was promised earlier, by a pass
1234 /// that measured a plan without paying for it, and is only now being
1235 /// charged for. A plan whose replay hits this was measured against more
1236 /// device memory than the replay has — warm the tune caches first, or
1237 /// measure a smaller one.
1238 #[error(
1239 "couldn't map storage for a deferred allocation of {size} bytes\nCaused by:\n {source}"
1240 )]
1241 StorageMappingFailed {
1242 /// The size of the allocation that could not be backed, in bytes.
1243 size: u64,
1244 /// Why the device allocation failed.
1245 source: Box<IoError>,
1246 /// The backtrace.
1247 #[cfg_attr(serializable, serde(skip))]
1248 backtrace: BackTrace,
1249 },
1250
1251 /// Unknown error happened during execution
1252 #[error("Unknown error happened during execution: {description}\n{backtrace}")]
1253 Unknown {
1254 /// Details of the error
1255 description: String,
1256 /// The backtrace.
1257 #[cfg_attr(serializable, serde(skip))]
1258 backtrace: BackTrace,
1259 },
1260
1261 /// The current IO operation is not supported
1262 #[error("The current IO operation is not supported\n{backtrace}")]
1263 UnsupportedIoOperation {
1264 /// The backtrace.
1265 #[cfg_attr(serializable, serde(skip))]
1266 backtrace: BackTrace,
1267 },
1268}
1269
1270impl IoError {
1271 /// Whether reclaiming memory could still make this allocation succeed.
1272 ///
1273 /// Out of memory *right now* is not out of memory for good: pool pages
1274 /// whose slices have all been dropped are still resident, and the frees
1275 /// that would release them may sit in a deferred drop queue. A transient
1276 /// peak — a model build holding float weights while their quantized copies
1277 /// allocate, an autotune sample on a full device — is rescued by a reclaim
1278 /// and a second attempt.
1279 ///
1280 /// A buffer larger than any page the device can hold is the exception. It
1281 /// never fits, so reclaiming would only spend the time.
1282 pub fn may_succeed_after_reclaim(&self) -> bool {
1283 !matches!(self, IoError::BufferTooBig { .. })
1284 }
1285}
1286
1287impl core::fmt::Debug for IoError {
1288 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1289 f.write_fmt(format_args!("{self}"))
1290 }
1291}
1292
1293/// Arguments to execute a kernel.
1294#[derive(Debug, Default)]
1295pub struct KernelArguments {
1296 /// Kernel bindings
1297 pub resources: Vec<KernelResource>,
1298 /// What the caller declared each resource is for, indexed like
1299 /// `resources`.
1300 ///
1301 /// The compiled kernel's own answer is better when it exists — the
1302 /// visibility analysis can prove a buffer write-only or dead, which a
1303 /// caller cannot — but it only exists once the kernel compiles. This one
1304 /// is stamped at the launch site from what the caller can see (a launch
1305 /// generated from `&Tensor` versus `&mut Tensor` knows it statically), so
1306 /// it survives the compile failing, which is exactly when it is needed: a
1307 /// launch that never ran must not taint the buffers it was only going to
1308 /// read. Missing entries read as [`ReadWrite`](BufferIOAttr::ReadWrite),
1309 /// so a caller that declares nothing keeps the loud fallback.
1310 pub declared_io: Vec<BufferIOAttr>,
1311 /// Packed scalars and metadata. First scalars sorted by type, then static metadata,
1312 /// then dynamic metadata.
1313 pub info: MetadataBindingInfo,
1314}
1315
1316impl core::fmt::Display for KernelArguments {
1317 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1318 f.write_str("KernelArguments")?;
1319 for b in self.resources.iter() {
1320 f.write_fmt(format_args!("\n - buffer: {b:?}\n"))?;
1321 }
1322
1323 Ok(())
1324 }
1325}
1326
1327impl KernelArguments {
1328 /// Create a new bindings struct
1329 pub fn new() -> Self {
1330 Self::default()
1331 }
1332
1333 /// Add a buffer binding
1334 pub fn with_buffer(mut self, binding: BufferBinding) -> Self {
1335 self.resources.push(KernelResource::Buffer(binding));
1336 self
1337 }
1338
1339 /// Add a buffer binding, declaring what the kernel does with it.
1340 ///
1341 /// The declaration is what a launch that fails before running — a kernel
1342 /// that does not compile above all — falls back on: only declared-writable
1343 /// buffers take the failure, so the ones the kernel was only going to read
1344 /// stay readable. Resources added without a declaration read as
1345 /// [`ReadWrite`](BufferIOAttr::ReadWrite), and mixing the two keeps every
1346 /// declaration on the resource it was made for.
1347 pub fn with_buffer_io(mut self, binding: BufferBinding, io: BufferIOAttr) -> Self {
1348 self.declared_io
1349 .resize(self.resources.len(), BufferIOAttr::ReadWrite);
1350 self.resources.push(KernelResource::Buffer(binding));
1351 self.declared_io.push(io);
1352 self
1353 }
1354
1355 /// Extend the buffers with `bindings`
1356 pub fn with_buffers(mut self, bindings: Vec<BufferBinding>) -> Self {
1357 let bindings = bindings.into_iter().map(KernelResource::Buffer);
1358 self.resources.extend(bindings);
1359 self
1360 }
1361
1362 /// Set the info to `info`
1363 pub fn with_info(mut self, info: MetadataBindingInfo) -> Self {
1364 self.info = info;
1365 self
1366 }
1367
1368 /// Extend the tensor maps with `bindings`
1369 pub fn with_tensor_maps(mut self, bindings: Vec<TensorMapBinding>) -> Self {
1370 let bindings = bindings.into_iter().map(KernelResource::TensorMap);
1371 self.resources.extend(bindings);
1372 self
1373 }
1374
1375 /// The buffers this launch was given.
1376 pub fn buffers(&self) -> impl Iterator<Item = &BufferBinding> {
1377 self.resources.iter().map(|resource| match resource {
1378 KernelResource::Buffer(binding) => binding,
1379 KernelResource::TensorMap(tensor_map) => &tensor_map.binding,
1380 })
1381 }
1382
1383 /// The memory this launch was given.
1384 pub fn memory_ids(&self) -> impl Iterator<Item = ManagedMemoryId> + '_ {
1385 self.buffers().map(|binding| binding.memory.id())
1386 }
1387
1388 /// The buffers this launch was given that the kernel writes, per the
1389 /// compiled kernel's own answer — the ones a launch that fails taints,
1390 /// and nothing else.
1391 ///
1392 /// `io` is what the compiler recorded from its visibility analysis,
1393 /// indexed like `resources` (see
1394 /// [`BufferIOAttr`](crate::kernel::BufferIOAttr)). An index it has no
1395 /// answer for falls back to the caller's declaration in `declared_io` —
1396 /// which is how a kernel that never compiled still taints only its
1397 /// outputs — and an index neither answers reads as written: naming a
1398 /// buffer the kernel only read fails a read that would have been fine,
1399 /// loudly; missing one it writes hands back the bytes that were there
1400 /// before, silently — so the last-resort fallback over-names.
1401 pub fn buffers_written<'a>(
1402 &'a self,
1403 io: Option<&'a [BufferIOAttr]>,
1404 ) -> impl Iterator<Item = &'a BufferBinding> {
1405 self.buffers()
1406 .enumerate()
1407 .filter_map(move |(index, binding)| {
1408 let written = self
1409 .io_attr(io, index)
1410 .map(|io| io.is_writable())
1411 .unwrap_or(true);
1412 written.then_some(binding)
1413 })
1414 }
1415
1416 /// The buffers this launch was given that the kernel reads — the ones
1417 /// whose contents have to be trustworthy before the launch runs, and the
1418 /// only ones checked: a pure output is not read, so a relaunch into a
1419 /// tainted buffer is exactly how the buffer gets repaired.
1420 ///
1421 /// The same fallback chain as [`buffers_written`](Self::buffers_written):
1422 /// compiled answer, then the caller's declaration, then read — so a
1423 /// kernel nobody kept an answer for is checked on everything rather than
1424 /// checked on nothing.
1425 pub fn buffers_read<'a>(
1426 &'a self,
1427 io: Option<&'a [BufferIOAttr]>,
1428 ) -> impl Iterator<Item = &'a BufferBinding> {
1429 self.buffers()
1430 .enumerate()
1431 .filter_map(move |(index, binding)| {
1432 let read = self
1433 .io_attr(io, index)
1434 .map(|io| io.is_readable())
1435 .unwrap_or(true);
1436 read.then_some(binding)
1437 })
1438 }
1439
1440 /// The answer for one resource: the compiled kernel's when it kept one,
1441 /// the caller's declaration otherwise, `None` when neither answered.
1442 fn io_attr(&self, compiled: Option<&[BufferIOAttr]>, index: usize) -> Option<BufferIOAttr> {
1443 compiled
1444 .and_then(|io| io.get(index))
1445 .or_else(|| self.declared_io.get(index))
1446 .copied()
1447 }
1448}
1449
1450/// Binding of a set of scalars of the same type to execute a kernel.
1451///
1452/// The [`Server`] is responsible to convert those info into actual [`Binding`] when launching
1453/// kernels.
1454#[derive(new, Debug, Default)]
1455pub struct MetadataBindingInfo {
1456 /// Scalar and metadata values
1457 pub data: Vec<u64>,
1458 /// Start of the dynamically sized portion of the metadata, relative to the entire info buffer
1459 pub dynamic_metadata_offset: usize,
1460}
1461
1462impl MetadataBindingInfo {
1463 /// Create a new binding info for custom data, for externally compiled kernels.
1464 pub fn custom(data: Vec<u64>) -> Self {
1465 Self::new(data, 0)
1466 }
1467}
1468
1469/// A binding with shape and stride info for non-contiguous reading
1470#[derive(new, Debug)]
1471pub struct CopyDescriptor {
1472 /// Binding for the memory resource
1473 pub handle: BufferBinding,
1474 /// Shape of the resource
1475 pub shape: Shape,
1476 /// Strides of the resource
1477 pub strides: Strides,
1478 /// Size of each element in the resource
1479 pub elem_size: usize,
1480}
1481
1482/// A tensor map used with TMA ops
1483#[derive(new, Clone, Debug)]
1484pub struct TensorMapBinding {
1485 /// The binding for the backing tensor
1486 pub binding: BufferBinding,
1487 /// The tensormap metadata
1488 pub map: TensorMapMeta,
1489}
1490
1491/// `TensorMap` metadata for the opaque proxy used in TMA copies
1492#[derive(Debug, Clone)]
1493pub struct TensorMapMeta {
1494 /// Tensormap format (tiled or im2col)
1495 pub format: TensorMapFormat,
1496 /// Metadata of the backing tensor
1497 pub metadata: Metadata,
1498 /// Element stride, usually 1 but may be 2 for complex tensors
1499 /// For im2col, this is equivalent to the kernel stride
1500 pub elem_stride: Strides,
1501 /// Interleave mode
1502 pub interleave: TensorMapInterleave,
1503 /// Swizzle mode
1504 pub swizzle: TensorMapSwizzle,
1505 /// Prefetch settings
1506 pub prefetch: TensorMapPrefetch,
1507 /// OOB fill value
1508 pub oob_fill: OobFill,
1509 /// Element type
1510 pub elem_ty: ElemType,
1511}
1512
1513/// Specifieds the number of cubes to be dispatched for a kernel.
1514///
1515/// This translates to eg. a grid for CUDA, or to `num_workgroups` for wgsl.
1516#[allow(clippy::large_enum_variant)]
1517pub enum CubeCount {
1518 /// Dispatch a known count of x, y, z cubes.
1519 Static(u32, u32, u32),
1520 /// Dispatch an amount based on the values in this buffer. The buffer should contain a u32 array [x, y, z].
1521 Dynamic(BufferBinding),
1522}
1523
1524/// Defines how to select cube count based on the number of cubes required.
1525pub enum CubeCountSelection {
1526 /// If the number of cubes is the same as required.
1527 Exact(CubeCount),
1528 /// If the number of cubes isn't the same as required.
1529 ///
1530 /// This can happen based on the hardware limit, requiring the kernel to perform OOB checks.
1531 Approx(CubeCount, u32),
1532}
1533
1534impl CubeCountSelection {
1535 /// Creates a [`CubeCount`] while respecting the hardware limits.
1536 pub fn new(client: &Client, num_cubes: u32) -> Self {
1537 let cube_count = cube_count_spread(&client.properties().hardware.max_cube_count, num_cubes);
1538
1539 let num_cubes_actual = cube_count[0] * cube_count[1] * cube_count[2];
1540 let cube_count = CubeCount::Static(cube_count[0], cube_count[1], cube_count[2]);
1541
1542 match num_cubes_actual == num_cubes {
1543 true => CubeCountSelection::Exact(cube_count),
1544 false => CubeCountSelection::Approx(cube_count, num_cubes_actual),
1545 }
1546 }
1547
1548 /// If some cubes will be idle.
1549 pub fn has_idle(&self) -> bool {
1550 matches!(self, Self::Approx(..))
1551 }
1552
1553 /// Converts into [`CubeCount`].
1554 pub fn cube_count(self) -> CubeCount {
1555 match self {
1556 CubeCountSelection::Exact(cube_count) => cube_count,
1557 CubeCountSelection::Approx(cube_count, _) => cube_count,
1558 }
1559 }
1560}
1561
1562impl From<CubeCountSelection> for CubeCount {
1563 fn from(value: CubeCountSelection) -> Self {
1564 value.cube_count()
1565 }
1566}
1567
1568impl CubeCount {
1569 /// Create a new static cube count with the given x = y = z = 1.
1570 pub fn new_single() -> Self {
1571 CubeCount::Static(1, 1, 1)
1572 }
1573
1574 /// Create a new static cube count with the given x, and y = z = 1.
1575 pub fn new_1d(x: u32) -> Self {
1576 CubeCount::Static(x, 1, 1)
1577 }
1578
1579 /// Create a new static cube count with the given x and y, and z = 1.
1580 pub fn new_2d(x: u32, y: u32) -> Self {
1581 CubeCount::Static(x, y, 1)
1582 }
1583
1584 /// Create a new static cube count with the given x, y and z.
1585 pub fn new_3d(x: u32, y: u32, z: u32) -> Self {
1586 CubeCount::Static(x, y, z)
1587 }
1588
1589 /// Checks whether the cube count is definitely empty, i.e. has 0 dispatches.
1590 pub fn is_empty(&self) -> bool {
1591 match self {
1592 Self::Static(x, y, z) => *x == 0 || *y == 0 || *z == 0,
1593 Self::Dynamic(_) => false,
1594 }
1595 }
1596}
1597
1598impl Debug for CubeCount {
1599 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1600 match self {
1601 CubeCount::Static(x, y, z) => f.write_fmt(format_args!("({x}, {y}, {z})")),
1602 CubeCount::Dynamic(_) => f.write_str("binding"),
1603 }
1604 }
1605}
1606
1607impl Clone for CubeCount {
1608 fn clone(&self) -> Self {
1609 match self {
1610 Self::Static(x, y, z) => Self::Static(*x, *y, *z),
1611 Self::Dynamic(binding) => Self::Dynamic(binding.clone()),
1612 }
1613 }
1614}
1615
1616#[derive(Debug, From, PartialEq, Eq, Clone, Copy, Hash, Deref, DerefMut)]
1617#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
1618#[allow(missing_docs)]
1619/// The number of units across all 3 axis totalling to the number of working units in a cube.
1620pub struct CubeDim(pub Dim3);
1621
1622impl CubeDim {
1623 /// Creates a new [`CubeDim`] based on the maximum number of tasks that can be parellalized by units, in other words,
1624 /// by the maximum number of working units.
1625 ///
1626 /// # Notes
1627 ///
1628 /// For complex problems, you probably want to have your own logic function to create the
1629 /// [`CubeDim`], but for simpler problems such as elemwise-operation, this is a great default.
1630 pub fn new(client: &Client, working_units: usize) -> Self {
1631 let properties = client.properties();
1632 let plane_size = properties.hardware.plane_size_max;
1633 let plane_count = Self::calculate_plane_count_per_cube(
1634 working_units as u32,
1635 plane_size,
1636 properties.hardware.num_cpu_cores,
1637 );
1638
1639 // Make sure it respects the max units per cube (especially on wasm)
1640 let limit = properties.hardware.max_units_per_cube / plane_size;
1641
1642 // Ensure at least 1 plane so CubeDim is always valid (num_elems() > 0).
1643 Self::new_2d(plane_size, u32::min(limit, plane_count).max(1))
1644 }
1645
1646 fn calculate_plane_count_per_cube(
1647 working_units: u32,
1648 plane_dim: u32,
1649 num_cpu_cores: Option<u32>,
1650 ) -> u32 {
1651 match num_cpu_cores {
1652 Some(num_cores) => core::cmp::min(num_cores, working_units),
1653 None => {
1654 let plane_count_max = core::cmp::max(1, working_units / plane_dim);
1655
1656 // Ensures `plane_count` is a power of 2.
1657 const NUM_PLANE_MAX: u32 = 8u32;
1658 const NUM_PLANE_MAX_LOG2: u32 = NUM_PLANE_MAX.ilog2();
1659 let plane_count_max_log2 =
1660 core::cmp::min(NUM_PLANE_MAX_LOG2, u32::ilog2(plane_count_max));
1661 2u32.pow(plane_count_max_log2)
1662 }
1663 }
1664 }
1665
1666 /// Create a new cube dim with x = y = z = 1.
1667 pub const fn new_single() -> Self {
1668 Self(Dim3::new_single())
1669 }
1670
1671 /// Create a new cube dim with the given x, and y = z = 1.
1672 pub const fn new_1d(x: u32) -> Self {
1673 Self(Dim3::new_1d(x))
1674 }
1675
1676 /// Create a new cube dim with the given x and y, and z = 1.
1677 pub const fn new_2d(x: u32, y: u32) -> Self {
1678 Self(Dim3::new_2d(x, y))
1679 }
1680
1681 /// Create a new cube dim with the given x, y and z.
1682 /// This is equivalent to the [new](CubeDim::new) function.
1683 pub const fn new_3d(x: u32, y: u32, z: u32) -> Self {
1684 Self(Dim3::new_3d(x, y, z))
1685 }
1686
1687 /// Total numbers of units per cube
1688 pub const fn num_elems(&self) -> u32 {
1689 self.0.num_elems()
1690 }
1691
1692 /// Whether this `CubeDim` can fully contain `other`
1693 pub const fn can_contain(&self, other: CubeDim) -> bool {
1694 self.0.can_contain(other.0)
1695 }
1696}
1697
1698impl From<(u32, u32, u32)> for CubeDim {
1699 fn from(value: (u32, u32, u32)) -> Self {
1700 CubeDim::new_3d(value.0, value.1, value.2)
1701 }
1702}
1703
1704impl From<CubeDim> for (u32, u32, u32) {
1705 fn from(val: CubeDim) -> Self {
1706 (val.x, val.y, val.z)
1707 }
1708}
1709
1710impl From<CubeDim> for Dim3 {
1711 fn from(value: CubeDim) -> Self {
1712 value.0
1713 }
1714}
1715
1716fn cube_count_spread(max: &(u32, u32, u32), num_cubes: u32) -> [u32; 3] {
1717 let max_cube_counts = [max.0, max.1, max.2];
1718 let mut num_cubes = [num_cubes, 1, 1];
1719 let base = 2;
1720
1721 let mut reduce_count = |i: usize| {
1722 if num_cubes[i] <= max_cube_counts[i] {
1723 return true;
1724 }
1725
1726 loop {
1727 num_cubes[i] = num_cubes[i].div_ceil(base);
1728 num_cubes[i + 1] *= base;
1729
1730 if num_cubes[i] <= max_cube_counts[i] {
1731 return false;
1732 }
1733 }
1734 };
1735
1736 for i in 0..2 {
1737 if reduce_count(i) {
1738 break;
1739 }
1740 }
1741
1742 num_cubes
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747 use super::*;
1748 use alloc::vec;
1749 use alloc::vec::Vec;
1750
1751 /// A service for handles that never reach a device.
1752 fn service() -> cubecl_common::device::ServiceId {
1753 cubecl_common::device::ServiceId::of::<()>(cubecl_common::device::DeviceId::new(0, 0))
1754 }
1755
1756 #[test_log::test]
1757 fn safe_num_cubes_even() {
1758 let max = (32, 32, 32);
1759 let required = 2048;
1760
1761 let actual = cube_count_spread(&max, required);
1762 let expected = [32, 32, 2];
1763 assert_eq!(actual, expected);
1764 }
1765
1766 #[test_log::test]
1767 fn safe_num_cubes_odd() {
1768 let max = (48, 32, 16);
1769 let required = 3177;
1770
1771 let actual = cube_count_spread(&max, required);
1772 let expected = [25, 32, 4];
1773 assert_eq!(actual, expected);
1774 }
1775
1776 /// The compiled kernel's answer drives both sets exactly, in resource
1777 /// order.
1778 #[test_log::test]
1779 fn buffer_io_drives_the_read_and_write_sets() {
1780 use crate::kernel::BufferIOAttr;
1781 use cubecl_environment::stream::StreamId;
1782
1783 let stream = StreamId { value: 0 };
1784 let args = KernelArguments::new().with_buffers(vec![
1785 Handle::new(service(), stream, 8).binding(),
1786 Handle::new(service(), stream, 8).binding(),
1787 Handle::new(service(), stream, 8).binding(),
1788 Handle::new(service(), stream, 8).binding(),
1789 ]);
1790 let io = [
1791 BufferIOAttr::ReadOnly,
1792 BufferIOAttr::WriteOnly,
1793 BufferIOAttr::ReadWrite,
1794 BufferIOAttr::Dead,
1795 ];
1796
1797 let written: Vec<_> = args.buffers_written(Some(&io)).collect();
1798 assert_eq!(written.len(), 2, "WriteOnly and ReadWrite are written");
1799 assert!(core::ptr::eq(written[0], args.buffers().nth(1).unwrap()));
1800 assert!(core::ptr::eq(written[1], args.buffers().nth(2).unwrap()));
1801
1802 let read: Vec<_> = args.buffers_read(Some(&io)).collect();
1803 assert_eq!(read.len(), 2, "ReadOnly and ReadWrite are read");
1804 assert!(core::ptr::eq(read[0], args.buffers().next().unwrap()));
1805 assert!(core::ptr::eq(read[1], args.buffers().nth(2).unwrap()));
1806 }
1807
1808 /// A refusal is the kernel being turned down, and nothing else is.
1809 ///
1810 /// The direction that matters is the false positive: a harness that takes
1811 /// a device fault for a refusal reports a broken run as a skipped one, and
1812 /// the test goes green. So a group answers yes only when every root does.
1813 #[test_log::test]
1814 fn only_a_refused_kernel_reads_as_a_refusal() {
1815 use crate::server::{LaunchError, ResourceLimitError};
1816
1817 let refused =
1818 ServerError::Launch(LaunchError::CompilationError(CompilationError::Generic {
1819 reason: "no such intrinsic on this target".into(),
1820 backtrace: Default::default(),
1821 }));
1822 let over_budget = ServerError::Launch(LaunchError::TooManyResources(
1823 ResourceLimitError::SharedMemory {
1824 requested: 1 << 20,
1825 max: 1 << 15,
1826 backtrace: Default::default(),
1827 },
1828 ));
1829 let fault = ServerError::Generic {
1830 reason: "the device faulted".into(),
1831 backtrace: Default::default(),
1832 };
1833
1834 assert!(refused.is_refusal());
1835 assert!(over_budget.is_refusal());
1836 assert!(!fault.is_refusal(), "a fault is not a refusal");
1837
1838 // A read reports the failure that stopped the buffer's writer, so the
1839 // question has to reach through the report to the root.
1840 let unwritten = |root: &ServerError| ServerError::Unwritten {
1841 failure: 1,
1842 claimed: 1,
1843 chain: Vec::new(),
1844 root: alloc::boxed::Box::new(root.clone()),
1845 backtrace: Default::default(),
1846 };
1847 assert!(unwritten(&refused).is_refusal());
1848 assert!(!unwritten(&fault).is_refusal());
1849
1850 let group = |errors: Vec<ServerError>| ServerError::Several {
1851 errors,
1852 backtrace: Default::default(),
1853 };
1854 assert!(group(vec![unwritten(&refused), unwritten(&over_budget)]).is_refusal());
1855 assert!(
1856 !group(vec![unwritten(&refused), unwritten(&fault)]).is_refusal(),
1857 "one real failure among refusals is still a real failure"
1858 );
1859 assert!(
1860 !group(Vec::new()).is_refusal(),
1861 "an empty group refuses nothing"
1862 );
1863 }
1864
1865 /// Every fallback over-names: a kernel the compiler kept no answer for,
1866 /// and a resource past what the answer covers, read as both read and
1867 /// written. Naming a buffer the kernel only read fails a read that would
1868 /// have been fine, loudly; missing one it writes hands back the bytes
1869 /// that were there before, silently.
1870 #[test_log::test]
1871 fn missing_io_reads_as_everything_read_and_written() {
1872 use crate::kernel::BufferIOAttr;
1873 use cubecl_environment::stream::StreamId;
1874
1875 let stream = StreamId { value: 0 };
1876 let args = KernelArguments::new().with_buffers(vec![
1877 Handle::new(service(), stream, 8).binding(),
1878 Handle::new(service(), stream, 8).binding(),
1879 ]);
1880
1881 assert_eq!(args.buffers_written(None).count(), 2);
1882 assert_eq!(args.buffers_read(None).count(), 2);
1883
1884 let short = [BufferIOAttr::Dead];
1885 assert_eq!(
1886 args.buffers_written(Some(&short)).count(),
1887 1,
1888 "the uncovered resource reads as written"
1889 );
1890 assert_eq!(args.buffers_read(Some(&short)).count(), 1);
1891 }
1892
1893 /// The caller's declaration answers when the compiled kernel kept none —
1894 /// which is what a launch that fails to compile falls back on, so it
1895 /// taints only its declared outputs — and the compiled answer still wins
1896 /// where it exists, since only the visibility analysis can prove a buffer
1897 /// write-only or dead.
1898 #[test_log::test]
1899 fn declared_io_answers_when_the_compiled_kernel_kept_none() {
1900 use crate::kernel::BufferIOAttr;
1901 use cubecl_environment::stream::StreamId;
1902
1903 let stream = StreamId { value: 0 };
1904 let args = KernelArguments::new()
1905 .with_buffer_io(
1906 Handle::new(service(), stream, 8).binding(),
1907 BufferIOAttr::ReadOnly,
1908 )
1909 .with_buffer_io(
1910 Handle::new(service(), stream, 8).binding(),
1911 BufferIOAttr::ReadOnly,
1912 )
1913 .with_buffer_io(
1914 Handle::new(service(), stream, 8).binding(),
1915 BufferIOAttr::WriteOnly,
1916 );
1917
1918 // No compiled answer: the declaration decides. The inputs are not
1919 // written, so a failed compile leaves them readable.
1920 let written: Vec<_> = args.buffers_written(None).collect();
1921 assert_eq!(written.len(), 1, "only the declared output is written");
1922 assert!(core::ptr::eq(written[0], args.buffers().nth(2).unwrap()));
1923 assert_eq!(args.buffers_read(None).count(), 2);
1924
1925 // A compiled answer overrides the declaration where it has one and
1926 // falls back to it where it does not.
1927 let compiled = [BufferIOAttr::ReadWrite];
1928 let written: Vec<_> = args.buffers_written(Some(&compiled)).collect();
1929 assert_eq!(written.len(), 2, "compiled ReadWrite plus declared output");
1930 assert!(core::ptr::eq(written[0], args.buffers().next().unwrap()));
1931 assert!(core::ptr::eq(written[1], args.buffers().nth(2).unwrap()));
1932 }
1933
1934 /// Declarations stay on the resource they were made for when declared and
1935 /// undeclared resources mix, and the undeclared ones keep the loud
1936 /// fallback.
1937 #[test_log::test]
1938 fn an_undeclared_resource_among_declared_ones_over_names() {
1939 use crate::kernel::BufferIOAttr;
1940 use cubecl_environment::stream::StreamId;
1941
1942 let stream = StreamId { value: 0 };
1943 let args = KernelArguments::new()
1944 .with_buffer(Handle::new(service(), stream, 8).binding())
1945 .with_buffer_io(
1946 Handle::new(service(), stream, 8).binding(),
1947 BufferIOAttr::ReadOnly,
1948 );
1949
1950 let written: Vec<_> = args.buffers_written(None).collect();
1951 assert_eq!(written.len(), 1, "the undeclared resource reads as written");
1952 assert!(core::ptr::eq(written[0], args.buffers().next().unwrap()));
1953 assert_eq!(args.buffers_read(None).count(), 2);
1954 }
1955}