Skip to main content

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        // What goes to the device is the elements in row-major order; a
148        // column-major argument is laid out once before it leaves, and a
149        // sparse one is expanded — a device buffer is one element per
150        // position and nothing else.
151        let laid_out;
152        let y = if y.is_row_major() && !y.is_sparse() {
153            y
154        } else {
155            laid_out = y.densified().to_row_major();
156            &laid_out
157        };
158        // A float array is uploaded from its own buffer; anything else is
159        // converted once, and the conversion becomes the host mirror.
160        let host = match &y.data {
161            Data::F64(_) => Host::Same(y.data.clone()),
162            Data::I64(v) => Host::Made(v.iter().map(|&x| x as f64).collect()),
163            Data::Bool(v) => Host::Made(v.iter().map(|&x| x as f64).collect()),
164            _ => {
165                return Err(DeviceError(
166                    "only boolean, integer and float arrays can be uploaded".into(),
167                ))
168            }
169        };
170        let handle = backend.upload(host.values(), self.precision)?;
171        let resident = Arc::new(Resident {
172            device: Arc::as_ptr(backend) as *const () as usize,
173            precision: self.precision,
174            elems: host.values().len(),
175            handle,
176            host,
177        });
178        let values = resident.host.values();
179        let (ptr, len) = (values.as_ptr(), values.len());
180        // SAFETY: the elements live inside the `Arc` this owner holds — in
181        // the array's own refcounted buffer or in the vector made for the
182        // upload — so they stay valid and unmutated for as long as the
183        // buffer that borrows them does.
184        let owner: Owner = resident;
185        Ok(Array::new(y.shape.clone(), Data::F64(unsafe { Buf::foreign(ptr, len, owner) })))
186    }
187
188    /// Is this array already resident on this device, at this precision?
189    pub fn holds(&self, y: &Array) -> bool {
190        self.backend().is_some_and(|b| resident_on(y, b, self.precision).is_some())
191    }
192}
193
194/// The elements an uploaded array's buffer borrows: the array's own, when
195/// it was already f64, or the conversion the upload had to make anyway.
196enum Host {
197    Same(Data),
198    Made(Vec<f64>),
199}
200
201impl Host {
202    fn values(&self) -> &[f64] {
203        match self {
204            Host::Same(Data::F64(v)) => v.as_slice(),
205            Host::Same(_) => &[],
206            Host::Made(v) => v,
207        }
208    }
209}
210
211/// A device allocation, and the host mirror an ordinary array reads.
212struct Resident {
213    /// Identifies the backend the allocation belongs to. Two devices that
214    /// share a backend share their uploads; a buffer from another one is
215    /// not usable and is re-uploaded.
216    device: usize,
217    precision: Precision,
218    elems: usize,
219    handle: Handle,
220    host: Host,
221}
222
223/// The device allocation behind this array's buffer, when it has one that
224/// belongs to `backend` at `precision`.
225fn resident_on<'a>(
226    y: &'a Array,
227    backend: &Arc<dyn Backend>,
228    precision: Precision,
229) -> Option<&'a Handle> {
230    let owner = y.data.owner()?;
231    let r: &Resident = owner.downcast_ref()?;
232    let same = r.device == Arc::as_ptr(backend) as *const () as usize
233        && r.precision == precision
234        && r.elems == y.data.len();
235    same.then_some(&r.handle)
236}
237
238/// A device operation that could not be carried out. These are host-side
239/// failures — no adapter, an allocation refused, a shader the driver would
240/// not compile — not language errors, and they never reach a program's
241/// diagnostics: the caller sees them from [`Device::upload`], and a run
242/// turns them into a fallback to the CPU.
243#[derive(Clone, Debug)]
244pub struct DeviceError(pub String);
245
246impl std::fmt::Display for DeviceError {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        f.write_str(&self.0)
249    }
250}
251
252impl std::error::Error for DeviceError {}
253
254/// An allocation on a device, opaque to everything but the backend that
255/// made it.
256pub(crate) struct Handle(pub Arc<dyn Any + Send + Sync>);
257
258/// One dispatch: a generated shader, the buffers it reads, and the grid.
259pub(crate) struct Plan<'a> {
260    pub source: &'a str,
261    pub entry: &'a str,
262    pub inputs: &'a [&'a Handle],
263    /// Elements the shader writes.
264    pub out_elems: usize,
265    pub elem_size: usize,
266    /// Elements the kernel maps over.
267    pub n: u32,
268    /// Threads in the grid, for the grid-stride loop a reduction runs.
269    pub stride: u32,
270    pub groups: u32,
271}
272
273/// What a device backend must provide for the fused-kernel path.
274///
275/// One implementation, [`gpu`], covers Metal, Vulkan and DX12 through wgpu.
276/// A second — CUDA, say — is another implementation of this trait and
277/// nothing else: the kernel description, the code generator and the
278/// placement rules above it are backend-agnostic.
279pub(crate) trait Backend: Send + Sync + 'static {
280    fn info(&self) -> &DeviceInfo;
281    /// Copy elements into a device buffer, in the device's element type.
282    fn upload(&self, values: &[f64], p: Precision) -> Result<Handle, DeviceError>;
283    /// Compile (or reuse) the plan's shader and run it, returning what it
284    /// wrote.
285    fn dispatch(&self, plan: &Plan<'_>) -> Result<Vec<u8>, DeviceError>;
286}
287
288// --------------------------------------------------------------- placement
289
290/// Why a fused node ran on the CPU although a device was asked for.
291///
292/// Every one of these is a statement about the kernel or its data, decided
293/// before any work happens, except [`Failed`](Refusal::Failed), which is the
294/// device itself refusing at run time. A fallback is always correct and only
295/// ever slower.
296#[derive(Clone, Debug, PartialEq, Eq)]
297pub enum Refusal {
298    /// The kernel's working type is i64. WGSL has no 64-bit integer
299    /// arithmetic on most adapters, so integer chains stay on the CPU.
300    Integer,
301    /// The chain's result is not f64 — a comparison at the root, a tally.
302    /// Narrowing a device result is not worth the risk this phase.
303    NotFloat,
304    /// The adapter has no f64 in shaders and the caller did not ask for
305    /// f32. See the module note on precision.
306    NoF64,
307    /// The generator does not cover one of the chain's operations at this
308    /// precision.
309    Unsupported(&'static str),
310    /// The kernel itself would decline these inputs, device or no device.
311    Declined,
312    /// The shader's answer holds an infinity or a NaN, where the dialect
313    /// has a rule of its own and the shader has only IEEE arithmetic.
314    NonFinite,
315    /// Too little data to pay for a dispatch.
316    TooSmall,
317    /// The device refused: an allocation, a shader, a queue submission.
318    Failed(String),
319}
320
321impl Refusal {
322    pub fn reason(&self) -> String {
323        match self {
324            Refusal::Integer => "the chain computes in 64-bit integers".into(),
325            Refusal::NotFloat => "the chain's result is not a float array".into(),
326            Refusal::NoF64 => {
327                "this adapter has no f64 in shaders; pass precision=\"f32\" to run anyway".into()
328            }
329            Refusal::Unsupported(op) => format!("`{op}` has no shader form here"),
330            Refusal::Declined => "the fused kernel declined these inputs".into(),
331            Refusal::NonFinite => {
332                "the answer holds an infinity or a NaN, which the dialect's rules read".into()
333            }
334            Refusal::TooSmall => "there is too little data to pay for a dispatch".into(),
335            Refusal::Failed(e) => format!("the device refused: {e}"),
336        }
337    }
338}
339
340/// Where a fused node's arithmetic happened.
341#[derive(Clone, Debug, PartialEq, Eq)]
342pub enum Placement {
343    /// No device was asked for, so the question did not arise.
344    Default,
345    Gpu,
346    /// The device would not take it, for this reason.
347    Cpu(Refusal),
348}
349
350impl std::fmt::Display for Placement {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        match self {
353            Placement::Default => Ok(()),
354            Placement::Gpu => write!(f, "device: gpu"),
355            Placement::Cpu(why) => write!(f, "device: cpu ({})", why.reason()),
356        }
357    }
358}
359
360/// Least elements worth a dispatch.
361///
362/// Below this the round trip — two submissions, a queue wait, a readback —
363/// costs more than the whole pass does on the CPU, whatever the arithmetic
364/// per element is. Measured on a Radeon Pro 560 against the 8-thread CPU
365/// path, the crossover for the simplest chain (`+/ w * x`) is around a
366/// million elements; the threshold is set an octave below that so that a
367/// heavier chain, which crosses over sooner, is not kept off the device.
368pub const MIN_ELEMS: usize = 1 << 19;
369
370/// Run a fused kernel on `device`, or say why it will not.
371pub(crate) fn try_run(
372    device: &Device,
373    k: &FusedKernel,
374    inputs: &[Array],
375) -> Result<Array, Refusal> {
376    let backend = device.backend().ok_or(Refusal::Declined)?;
377    let precision = device.precision;
378    if precision == Precision::F64 && !backend.info().f64 {
379        return Err(Refusal::NoF64);
380    }
381    // A tally never touches values, and a reduction over one item is the
382    // item itself: both are the fused path's own answers, exactly.
383    if k.yields() == Yield::Tally {
384        return Err(Refusal::Declined);
385    }
386    let Some(Some(shape)) = crate::fuse::common_shape(inputs) else {
387        return Err(Refusal::Declined);
388    };
389    let n: usize = shape.iter().product();
390    if n < MIN_ELEMS {
391        return Err(Refusal::TooSmall);
392    }
393    let reducing = k.reduce().is_some();
394    if reducing && shape.len() != 1 {
395        return Err(Refusal::Declined);
396    }
397    let (working, root) = crate::fuse::working_type(k, inputs).ok_or(Refusal::Declined)?;
398    if working != DType::F64 {
399        return Err(Refusal::Integer);
400    }
401    if root != DType::F64 {
402        return Err(Refusal::NotFloat);
403    }
404
405    // Every input either lies on the device already or goes up now. A
406    // rank-0 input becomes a one-element buffer the shader reads at 0.
407    let splat: Vec<bool> = inputs.iter().map(|a| a.rank() == 0).collect();
408    let source = codegen::wgsl(k, &splat, precision).map_err(Refusal::Unsupported)?;
409
410    let mut temporaries: Vec<Handle> = Vec::new();
411    let mut slots: Vec<Option<&Handle>> = Vec::with_capacity(inputs.len());
412    for a in inputs {
413        match resident_on(a, backend, precision) {
414            Some(h) => slots.push(Some(h)),
415            None => {
416                // A float argument goes up from its own buffer; only a
417                // boolean or integer one is converted, and copying tens of
418                // megabytes for nothing is exactly what that would be.
419                let h = match &a.data {
420                    Data::F64(v) => backend.upload(v.as_slice(), precision),
421                    _ => backend.upload(&as_f64_vec(a), precision),
422                }
423                .map_err(|e| Refusal::Failed(e.0))?;
424                temporaries.push(h);
425                slots.push(None);
426            }
427        }
428    }
429    let mut next = 0usize;
430    let buffers: Vec<&Handle> = slots
431        .iter()
432        .map(|s| match s {
433            Some(h) => *h,
434            None => {
435                let h = &temporaries[next];
436                next += 1;
437                h
438            }
439        })
440        .collect();
441
442    let elem_size = precision.size();
443    let out = if reducing {
444        let groups = codegen::groups_for(n);
445        let plan = Plan {
446            source: &source,
447            entry: codegen::REDUCE,
448            inputs: &buffers,
449            out_elems: groups,
450            elem_size,
451            n: n as u32,
452            stride: (groups * codegen::WORKGROUP) as u32,
453            groups: groups as u32,
454        };
455        let bytes = backend.dispatch(&plan).map_err(|e| Refusal::Failed(e.0))?;
456        let partials = codegen::from_bytes(&bytes, precision, groups);
457        // The partials combine right to left, as the CPU path's chunks do.
458        // Only associative operations are absorbed, so this is the same
459        // regrouping the float contract (§5.9) already allows.
460        let op = k.reduce().expect("reducing");
461        let mut acc = *partials.last().ok_or(Refusal::Declined)?;
462        for &v in partials[..partials.len() - 1].iter().rev() {
463            acc = crate::fuse::step(op, v, acc).ok_or(Refusal::Declined)?;
464        }
465        Array::scalar_f64(acc)
466    } else {
467        let plan = Plan {
468            source: &source,
469            entry: codegen::MAP,
470            inputs: &buffers,
471            out_elems: n,
472            elem_size,
473            n: n as u32,
474            stride: 0,
475            groups: n.div_ceil(codegen::WORKGROUP) as u32,
476        };
477        let bytes = backend.dispatch(&plan).map_err(|e| Refusal::Failed(e.0))?;
478        let values = codegen::from_bytes(&bytes, precision, n);
479        Array::new(shape, Data::F64(values.into()))
480    };
481    Ok(out)
482}
483
484fn as_f64_vec(a: &Array) -> Vec<f64> {
485    match &a.data {
486        Data::F64(v) => v.as_slice().to_vec(),
487        Data::I64(v) => v.iter().map(|&x| x as f64).collect(),
488        Data::Bool(v) => v.iter().map(|&x| x as f64).collect(),
489        _ => Vec::new(),
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn the_cpu_is_always_a_device() {
499        let d = Device::cpu();
500        assert!(!d.is_gpu());
501        assert!(d.info().is_none());
502        let a = Array::from_f64(vec![1.0, 2.0]);
503        assert_eq!(d.upload(&a).expect("cpu upload"), a);
504    }
505
506    #[test]
507    fn every_refusal_says_something() {
508        for r in [
509            Refusal::Integer,
510            Refusal::NotFloat,
511            Refusal::NoF64,
512            Refusal::Unsupported("^"),
513            Refusal::Declined,
514            Refusal::TooSmall,
515            Refusal::Failed("no adapter".into()),
516        ] {
517            assert!(!r.reason().is_empty());
518        }
519    }
520}