jay/device/mod.rs
1//! Where a compiled expression runs.
2//!
3//! Placement is deliberately not part of binding. A kernel bound to data is
4//! the same kernel wherever it executes; a [`Device`] says which processor
5//! executes it, and the CPU is one of the answers. `Program::run_on` takes
6//! the device explicitly, and everything a device cannot do falls back to
7//! the CPU path with a reason a caller can read.
8//!
9//! What runs on a GPU this phase is the fused elementwise kernel and nothing
10//! else. [`crate::fuse`] already compiles a chain of scalar verbs into a
11//! postfix program over blocks, with an optional reduction folded in — that
12//! is a kernel description, and `codegen` turns it into WGSL at run time.
13//! Anything outside a fused node, and any fused node the generator declines,
14//! runs where it always ran.
15//!
16//! # Precision
17//!
18//! libjay computes floats in f64. WGSL can express f64, but almost no
19//! adapter implements it: Metal has no double at all, and on Vulkan it is a
20//! feature (`SHADER_F64`) that many drivers leave off. A device that cannot
21//! run f64 therefore **declines** by default rather than quietly computing
22//! in f32 — losing precision is not a performance decision libjay may take
23//! on the caller's behalf. `Precision::F32` is the caller saying, in so many
24//! words, that they want it.
25//!
26//! # Residency
27//!
28//! [`Device::upload`] returns an array that carries its own location: the
29//! buffer it hands back keeps the device allocation alive inside its owner
30//! handle, so passing it to a later run uploads nothing. The array is an
31//! ordinary [`Array`] otherwise, which is what lets a fallback to the CPU
32//! read it without asking anyone.
33
34mod codegen;
35mod gpu;
36
37use std::any::Any;
38use std::sync::Arc;
39
40use crate::array::{Array, Buf, Data, Owner};
41use crate::dtype::DType;
42use crate::fuse::{FusedKernel, Yield};
43
44pub use codegen::Precision;
45
46/// One adapter, as the machine reports it.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct DeviceInfo {
49 /// The adapter's own name, e.g. "AMD Radeon Pro 560".
50 pub name: String,
51 /// The API behind it: Metal, Vulkan, DX12.
52 pub backend: String,
53 /// discrete GPU, integrated GPU, virtual GPU, CPU, or other.
54 pub kind: String,
55 /// Whether shaders on this adapter can compute in f64. Where this is
56 /// false, only an explicit `Precision::F32` reaches the device.
57 pub f64: bool,
58}
59
60/// Every adapter this machine offers, in the order the backend ranks them.
61/// Empty on a machine with no GPU, which is not an error.
62pub fn available() -> Vec<DeviceInfo> {
63 gpu::enumerate()
64}
65
66/// Where a program runs.
67///
68/// Cloning is cheap: the GPU handle is shared, so two clones name the same
69/// adapter and the same uploaded buffers.
70#[derive(Clone)]
71pub struct Device {
72 at: Where,
73 precision: Precision,
74}
75
76#[derive(Clone)]
77enum Where {
78 Cpu,
79 Gpu(Arc<dyn Backend>),
80}
81
82impl std::fmt::Debug for Device {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match &self.at {
85 Where::Cpu => write!(f, "Device(cpu)"),
86 Where::Gpu(g) => {
87 write!(f, "Device({}, {:?})", g.info().name, self.precision)
88 }
89 }
90 }
91}
92
93impl Device {
94 /// The processor everything already ran on.
95 pub fn cpu() -> Device {
96 Device { at: Where::Cpu, precision: Precision::F64 }
97 }
98
99 /// The machine's preferred adapter, or None where there is none.
100 ///
101 /// The adapter is opened once per process and shared; asking twice
102 /// costs nothing and hands back the same device.
103 pub fn default_gpu() -> Option<Device> {
104 Some(Device { at: Where::Gpu(gpu::shared()?), precision: Precision::F64 })
105 }
106
107 /// The same device, computing in `p`.
108 ///
109 /// `Precision::F32` is an explicit request to compute a f64 program in
110 /// single precision. It is the only way a machine whose shaders have no
111 /// f64 runs anything at all on its GPU.
112 pub fn with_precision(&self, p: Precision) -> Device {
113 Device { at: self.at.clone(), precision: p }
114 }
115
116 pub fn precision(&self) -> Precision {
117 self.precision
118 }
119
120 pub fn is_gpu(&self) -> bool {
121 matches!(self.at, Where::Gpu(_))
122 }
123
124 /// What this device is, or None for the CPU.
125 pub fn info(&self) -> Option<&DeviceInfo> {
126 match &self.at {
127 Where::Cpu => None,
128 Where::Gpu(g) => Some(g.info()),
129 }
130 }
131
132 fn backend(&self) -> Option<&Arc<dyn Backend>> {
133 match &self.at {
134 Where::Cpu => None,
135 Where::Gpu(g) => Some(g),
136 }
137 }
138
139 /// `y` with its elements resident on this device.
140 ///
141 /// The result is an ordinary array — same shape, same values, readable
142 /// by anything — that additionally holds the device allocation, so a
143 /// run that reaches the device with it uploads nothing. Uploading to
144 /// the CPU is the identity.
145 pub fn upload(&self, y: &Array) -> Result<Array, DeviceError> {
146 let Some(backend) = self.backend() else { return Ok(y.clone()) };
147 // A float array is uploaded from its own buffer; anything else is
148 // converted once, and the conversion becomes the host mirror.
149 let host = match &y.data {
150 Data::F64(_) => Host::Same(y.data.clone()),
151 Data::I64(v) => Host::Made(v.iter().map(|&x| x as f64).collect()),
152 Data::Bool(v) => Host::Made(v.iter().map(|&x| x as f64).collect()),
153 _ => {
154 return Err(DeviceError(
155 "only boolean, integer and float arrays can be uploaded".into(),
156 ))
157 }
158 };
159 let handle = backend.upload(host.values(), self.precision)?;
160 let resident = Arc::new(Resident {
161 device: Arc::as_ptr(backend) as *const () as usize,
162 precision: self.precision,
163 elems: host.values().len(),
164 handle,
165 host,
166 });
167 let values = resident.host.values();
168 let (ptr, len) = (values.as_ptr(), values.len());
169 // SAFETY: the elements live inside the `Arc` this owner holds — in
170 // the array's own refcounted buffer or in the vector made for the
171 // upload — so they stay valid and unmutated for as long as the
172 // buffer that borrows them does.
173 let owner: Owner = resident;
174 Ok(Array::new(y.shape.clone(), Data::F64(unsafe { Buf::foreign(ptr, len, owner) })))
175 }
176
177 /// Is this array already resident on this device, at this precision?
178 pub fn holds(&self, y: &Array) -> bool {
179 self.backend().is_some_and(|b| resident_on(y, b, self.precision).is_some())
180 }
181}
182
183/// The elements an uploaded array's buffer borrows: the array's own, when
184/// it was already f64, or the conversion the upload had to make anyway.
185enum Host {
186 Same(Data),
187 Made(Vec<f64>),
188}
189
190impl Host {
191 fn values(&self) -> &[f64] {
192 match self {
193 Host::Same(Data::F64(v)) => v.as_slice(),
194 Host::Same(_) => &[],
195 Host::Made(v) => v,
196 }
197 }
198}
199
200/// A device allocation, and the host mirror an ordinary array reads.
201struct Resident {
202 /// Identifies the backend the allocation belongs to. Two devices that
203 /// share a backend share their uploads; a buffer from another one is
204 /// not usable and is re-uploaded.
205 device: usize,
206 precision: Precision,
207 elems: usize,
208 handle: Handle,
209 host: Host,
210}
211
212/// The device allocation behind this array's buffer, when it has one that
213/// belongs to `backend` at `precision`.
214fn resident_on<'a>(
215 y: &'a Array,
216 backend: &Arc<dyn Backend>,
217 precision: Precision,
218) -> Option<&'a Handle> {
219 let owner = y.data.owner()?;
220 let r: &Resident = owner.downcast_ref()?;
221 let same = r.device == Arc::as_ptr(backend) as *const () as usize
222 && r.precision == precision
223 && r.elems == y.data.len();
224 same.then_some(&r.handle)
225}
226
227/// A device operation that could not be carried out. These are host-side
228/// failures — no adapter, an allocation refused, a shader the driver would
229/// not compile — not language errors, and they never reach a program's
230/// diagnostics: the caller sees them from [`Device::upload`], and a run
231/// turns them into a fallback to the CPU.
232#[derive(Clone, Debug)]
233pub struct DeviceError(pub String);
234
235impl std::fmt::Display for DeviceError {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.write_str(&self.0)
238 }
239}
240
241impl std::error::Error for DeviceError {}
242
243/// An allocation on a device, opaque to everything but the backend that
244/// made it.
245pub(crate) struct Handle(pub Arc<dyn Any + Send + Sync>);
246
247/// One dispatch: a generated shader, the buffers it reads, and the grid.
248pub(crate) struct Plan<'a> {
249 pub source: &'a str,
250 pub entry: &'a str,
251 pub inputs: &'a [&'a Handle],
252 /// Elements the shader writes.
253 pub out_elems: usize,
254 pub elem_size: usize,
255 /// Elements the kernel maps over.
256 pub n: u32,
257 /// Threads in the grid, for the grid-stride loop a reduction runs.
258 pub stride: u32,
259 pub groups: u32,
260}
261
262/// What a device backend must provide for the fused-kernel path.
263///
264/// One implementation, [`gpu`], covers Metal, Vulkan and DX12 through wgpu.
265/// A second — CUDA, say — is another implementation of this trait and
266/// nothing else: the kernel description, the code generator and the
267/// placement rules above it are backend-agnostic.
268pub(crate) trait Backend: Send + Sync + 'static {
269 fn info(&self) -> &DeviceInfo;
270 /// Copy elements into a device buffer, in the device's element type.
271 fn upload(&self, values: &[f64], p: Precision) -> Result<Handle, DeviceError>;
272 /// Compile (or reuse) the plan's shader and run it, returning what it
273 /// wrote.
274 fn dispatch(&self, plan: &Plan<'_>) -> Result<Vec<u8>, DeviceError>;
275}
276
277// --------------------------------------------------------------- placement
278
279/// Why a fused node ran on the CPU although a device was asked for.
280///
281/// Every one of these is a statement about the kernel or its data, decided
282/// before any work happens, except [`Failed`](Refusal::Failed), which is the
283/// device itself refusing at run time. A fallback is always correct and only
284/// ever slower.
285#[derive(Clone, Debug, PartialEq, Eq)]
286pub enum Refusal {
287 /// The kernel's working type is i64. WGSL has no 64-bit integer
288 /// arithmetic on most adapters, so integer chains stay on the CPU.
289 Integer,
290 /// The chain's result is not f64 — a comparison at the root, a tally.
291 /// Narrowing a device result is not worth the risk this phase.
292 NotFloat,
293 /// The adapter has no f64 in shaders and the caller did not ask for
294 /// f32. See the module note on precision.
295 NoF64,
296 /// The generator does not cover one of the chain's operations at this
297 /// precision.
298 Unsupported(&'static str),
299 /// The kernel itself would decline these inputs, device or no device.
300 Declined,
301 /// Too little data to pay for a dispatch.
302 TooSmall,
303 /// The device refused: an allocation, a shader, a queue submission.
304 Failed(String),
305}
306
307impl Refusal {
308 pub fn reason(&self) -> String {
309 match self {
310 Refusal::Integer => "the chain computes in 64-bit integers".into(),
311 Refusal::NotFloat => "the chain's result is not a float array".into(),
312 Refusal::NoF64 => {
313 "this adapter has no f64 in shaders; pass precision=\"f32\" to run anyway".into()
314 }
315 Refusal::Unsupported(op) => format!("`{op}` has no shader form here"),
316 Refusal::Declined => "the fused kernel declined these inputs".into(),
317 Refusal::TooSmall => "there is too little data to pay for a dispatch".into(),
318 Refusal::Failed(e) => format!("the device refused: {e}"),
319 }
320 }
321}
322
323/// Where a fused node's arithmetic happened.
324#[derive(Clone, Debug, PartialEq, Eq)]
325pub enum Placement {
326 /// No device was asked for, so the question did not arise.
327 Default,
328 Gpu,
329 /// The device would not take it, for this reason.
330 Cpu(Refusal),
331}
332
333impl std::fmt::Display for Placement {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 match self {
336 Placement::Default => Ok(()),
337 Placement::Gpu => write!(f, "device: gpu"),
338 Placement::Cpu(why) => write!(f, "device: cpu ({})", why.reason()),
339 }
340 }
341}
342
343/// Least elements worth a dispatch.
344///
345/// Below this the round trip — two submissions, a queue wait, a readback —
346/// costs more than the whole pass does on the CPU, whatever the arithmetic
347/// per element is. Measured on a Radeon Pro 560 against the 8-thread CPU
348/// path, the crossover for the simplest chain (`+/ w * x`) is around a
349/// million elements; the threshold is set an octave below that so that a
350/// heavier chain, which crosses over sooner, is not kept off the device.
351pub const MIN_ELEMS: usize = 1 << 19;
352
353/// Run a fused kernel on `device`, or say why it will not.
354pub(crate) fn try_run(
355 device: &Device,
356 k: &FusedKernel,
357 inputs: &[Array],
358) -> Result<Array, Refusal> {
359 let backend = device.backend().ok_or(Refusal::Declined)?;
360 let precision = device.precision;
361 if precision == Precision::F64 && !backend.info().f64 {
362 return Err(Refusal::NoF64);
363 }
364 // A tally never touches values, and a reduction over one item is the
365 // item itself: both are the fused path's own answers, exactly.
366 if k.yields() == Yield::Tally {
367 return Err(Refusal::Declined);
368 }
369 let Some(Some(shape)) = crate::fuse::common_shape(inputs) else {
370 return Err(Refusal::Declined);
371 };
372 let n: usize = shape.iter().product();
373 if n < MIN_ELEMS {
374 return Err(Refusal::TooSmall);
375 }
376 let reducing = k.reduce().is_some();
377 if reducing && shape.len() != 1 {
378 return Err(Refusal::Declined);
379 }
380 let (working, root) = crate::fuse::working_type(k, inputs).ok_or(Refusal::Declined)?;
381 if working != DType::F64 {
382 return Err(Refusal::Integer);
383 }
384 if root != DType::F64 {
385 return Err(Refusal::NotFloat);
386 }
387
388 // Every input either lies on the device already or goes up now. A
389 // rank-0 input becomes a one-element buffer the shader reads at 0.
390 let splat: Vec<bool> = inputs.iter().map(|a| a.rank() == 0).collect();
391 let source = codegen::wgsl(k, &splat, precision).map_err(Refusal::Unsupported)?;
392
393 let mut temporaries: Vec<Handle> = Vec::new();
394 let mut slots: Vec<Option<&Handle>> = Vec::with_capacity(inputs.len());
395 for a in inputs {
396 match resident_on(a, backend, precision) {
397 Some(h) => slots.push(Some(h)),
398 None => {
399 // A float argument goes up from its own buffer; only a
400 // boolean or integer one is converted, and copying tens of
401 // megabytes for nothing is exactly what that would be.
402 let h = match &a.data {
403 Data::F64(v) => backend.upload(v.as_slice(), precision),
404 _ => backend.upload(&as_f64_vec(a), precision),
405 }
406 .map_err(|e| Refusal::Failed(e.0))?;
407 temporaries.push(h);
408 slots.push(None);
409 }
410 }
411 }
412 let mut next = 0usize;
413 let buffers: Vec<&Handle> = slots
414 .iter()
415 .map(|s| match s {
416 Some(h) => *h,
417 None => {
418 let h = &temporaries[next];
419 next += 1;
420 h
421 }
422 })
423 .collect();
424
425 let elem_size = precision.size();
426 let out = if reducing {
427 let groups = codegen::groups_for(n);
428 let plan = Plan {
429 source: &source,
430 entry: codegen::REDUCE,
431 inputs: &buffers,
432 out_elems: groups,
433 elem_size,
434 n: n as u32,
435 stride: (groups * codegen::WORKGROUP) as u32,
436 groups: groups as u32,
437 };
438 let bytes = backend.dispatch(&plan).map_err(|e| Refusal::Failed(e.0))?;
439 let partials = codegen::from_bytes(&bytes, precision, groups);
440 // The partials combine right to left, as the CPU path's chunks do.
441 // Only associative operations are absorbed, so this is the same
442 // regrouping the float contract (§5.9) already allows.
443 let op = k.reduce().expect("reducing");
444 let mut acc = *partials.last().ok_or(Refusal::Declined)?;
445 for &v in partials[..partials.len() - 1].iter().rev() {
446 acc = crate::fuse::step(op, v, acc).ok_or(Refusal::Declined)?;
447 }
448 Array::scalar_f64(acc)
449 } else {
450 let plan = Plan {
451 source: &source,
452 entry: codegen::MAP,
453 inputs: &buffers,
454 out_elems: n,
455 elem_size,
456 n: n as u32,
457 stride: 0,
458 groups: n.div_ceil(codegen::WORKGROUP) as u32,
459 };
460 let bytes = backend.dispatch(&plan).map_err(|e| Refusal::Failed(e.0))?;
461 let values = codegen::from_bytes(&bytes, precision, n);
462 Array::new(shape, Data::F64(values.into()))
463 };
464 Ok(out)
465}
466
467fn as_f64_vec(a: &Array) -> Vec<f64> {
468 match &a.data {
469 Data::F64(v) => v.as_slice().to_vec(),
470 Data::I64(v) => v.iter().map(|&x| x as f64).collect(),
471 Data::Bool(v) => v.iter().map(|&x| x as f64).collect(),
472 _ => Vec::new(),
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn the_cpu_is_always_a_device() {
482 let d = Device::cpu();
483 assert!(!d.is_gpu());
484 assert!(d.info().is_none());
485 let a = Array::from_f64(vec![1.0, 2.0]);
486 assert_eq!(d.upload(&a).expect("cpu upload"), a);
487 }
488
489 #[test]
490 fn every_refusal_says_something() {
491 for r in [
492 Refusal::Integer,
493 Refusal::NotFloat,
494 Refusal::NoF64,
495 Refusal::Unsupported("^"),
496 Refusal::Declined,
497 Refusal::TooSmall,
498 Refusal::Failed("no adapter".into()),
499 ] {
500 assert!(!r.reason().is_empty());
501 }
502 }
503}