Skip to main content

kopitiam_gpu/
context.rs

1//! The GPU handle, and the one place we probe for a GPU.
2//!
3//! `GpuContext` owns the live `wgpu::Device` + `wgpu::Queue` for the machine we
4//! are actually running on. Getting one is fallible on purpose: plenty of real
5//! machines KOPITIAM runs on have no usable GPU — headless CI, a GPU-less
6//! tablet, a server with no drivers installed, an Android Termux userland with
7//! no Vulkan ICD installed. On all of those, [`GpuContext::new`] returns
8//! `Err(GpuUnavailable)` and **never panics**. The whole point of this crate's
9//! cascade is that "no GPU" is an ordinary runtime answer, not a crash.
10//!
11//! ## Headless by design
12//!
13//! We request an adapter and device with **no surface / no window**
14//! (`compatible_surface: None`) — this is compute only: pipelines + buffer
15//! readback, nothing drawn to a screen. That surface-free design is exactly what
16//! lets the crate run under **Android Termux**, which has no display context, no
17//! `Activity`, and no `ANativeWindow`. We never touch JNI or a window handle for
18//! compute. (Render pipelines / windowing are deliberately out of scope for the
19//! first cut.)
20//!
21//! Probe ONCE, cache the handle. Enumerating adapters and opening a device is
22//! not free (millisecond-ish, and it spins up driver state), so a long-lived
23//! program should build one `GpuContext` (or one [`crate::Executor`], which
24//! holds one) and reuse it, not rebuild it per operation.
25
26use thiserror::Error;
27
28/// Why we could not get a GPU on this machine.
29///
30/// This is a plain "no usable GPU" signal, not a rich diagnostic — callers that
31/// see it should stop trying the GPU and take the CPU path (that is exactly what
32/// [`crate::Executor`] does). The two ways it happens map to the two async wgpu
33/// steps that can fail with no hardware behind them.
34#[derive(Debug, Error)]
35pub enum GpuUnavailable {
36    /// wgpu found no adapter matching our request — i.e. no GPU (or no driver
37    /// wgpu can talk to) is present at all. On Termux this is the common
38    /// "no Vulkan ICD installed / immature Mali driver" case. `String` because
39    /// wgpu's own `RequestAdapterError` is not `'static`-friendly to store; we
40    /// keep its message for the log, not for matching on.
41    #[error("no GPU adapter available: {0}")]
42    NoAdapter(String),
43
44    /// An adapter existed but opening a logical device on it failed (driver in
45    /// a bad state, limits we asked for unsupported, and so on).
46    #[error("GPU adapter found but opening a device failed: {0}")]
47    NoDevice(String),
48}
49
50/// A live GPU handle: one logical device, its command queue, and a snapshot of
51/// which adapter we ended up on (so the maintainer can VERIFY, on-device,
52/// whether the GPU actually engaged or we fell back to CPU).
53///
54/// Cheap to pass around by reference (`&GpuContext`); do not rebuild it per op.
55/// Held for the lifetime of an [`crate::Executor`].
56#[derive(Debug)]
57pub struct GpuContext {
58    device: wgpu::Device,
59    queue: wgpu::Queue,
60    /// Captured at construction from `adapter.get_info()`. Kept so
61    /// [`describe_backend`](Self::describe_backend) works long after the adapter
62    /// handle itself is gone.
63    info: wgpu::AdapterInfo,
64}
65
66impl GpuContext {
67    /// Probe for a GPU and open a device on it, or return why we can't.
68    ///
69    /// Blocking: wgpu's `request_adapter` / `request_device` are async, and this
70    /// crate's API is synchronous, so we drive them to completion with
71    /// `pollster::block_on`. Call this once and cache the result.
72    ///
73    /// We ask for [`wgpu::Limits::downlevel_defaults`] rather than the desktop
74    /// defaults so the device also opens on mobile/GL-class adapters — this
75    /// crate is meant to run on GPU-less *and* modest-GPU devices, and the
76    /// demonstrator kernels stay well inside downlevel limits.
77    ///
78    /// ## The Termux GPU reality (hard-won, read before touching this)
79    ///
80    /// Whether a GPU adapter is even *reachable* from the Termux userland is
81    /// **device + driver dependent**, not a wgpu limitation. Android GPU access
82    /// from Termux goes through Vulkan (preferred) or GLES, which is why the
83    /// instance below enables exactly `VULKAN | GL`:
84    ///
85    /// * **Adreno (Qualcomm / Snapdragon):** works via Termux's Mesa
86    ///   **Turnip/Freedreno** Vulkan ICD — install `mesa-vulkan-icd-freedreno`
87    ///   plus the Vulkan loader (`vulkan-loader`). This is the best-supported
88    ///   on-device path and what the README's test recipe targets.
89    /// * **Mali (ARM):** the open **Panfrost/PanVK** stack is immature; it may
90    ///   present *no usable Vulkan adapter*, in which case we get `NoAdapter`
91    ///   and the cascade lands on CPU. That is expected, not a bug.
92    /// * **No ICD installed:** no adapter is found at all → `NoAdapter` → CPU.
93    ///
94    /// In every one of those "no adapter / no driver" cases this returns `Err`
95    /// and the [`crate::Executor`] cascade falls to the pure-Rust CPU path — no
96    /// crash, correct answer, just slower. Nothing here requires a working GPU.
97    pub fn new() -> Result<Self, GpuUnavailable> {
98        // Vulkan + GL only: those are the two backends that matter on Android/
99        // Termux (Vulkan via the Mesa Turnip ICD, GLES as the fallback), and
100        // they cover desktop Linux/Windows too. We do NOT ask for Metal/DX12
101        // here — not because they'd hurt, but because VULKAN|GL is the honest
102        // portable-compute set for our actual target devices; widen to
103        // `Backends::all()` later if a Metal/DX12 host ever needs it.
104        // InstanceDescriptor has no `Default` (its `display` field holds a boxed
105        // trait object), so every field is spelled out. The sub-descriptors do
106        // default, and `display: None` = headless, which is what we want.
107        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
108            backends: wgpu::Backends::VULKAN | wgpu::Backends::GL,
109            flags: wgpu::InstanceFlags::default(),
110            memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
111            backend_options: wgpu::BackendOptions::default(),
112            display: None,
113        });
114
115        // No surface: headless compute, so `compatible_surface: None`. This is
116        // the line that keeps us runnable under Termux (no display context).
117        // `force_fallback_adapter: false` = take a real GPU if there is one.
118        let adapter = pollster::block_on(instance.request_adapter(
119            &wgpu::RequestAdapterOptions {
120                power_preference: wgpu::PowerPreference::HighPerformance,
121                compatible_surface: None,
122                force_fallback_adapter: false,
123                // Don't bucket-round the adapter's reported limits; take them
124                // as-is (we only need downlevel defaults anyway).
125                apply_limit_buckets: false,
126            },
127        ))
128        .map_err(|e| GpuUnavailable::NoAdapter(e.to_string()))?;
129
130        // Snapshot the adapter identity now (name + backend + driver) so we can
131        // report it. `get_info` borrows the adapter; `request_device` below also
132        // only borrows it, so both are fine before the adapter drops.
133        let info = adapter.get_info();
134
135        let (device, queue) = pollster::block_on(adapter.request_device(
136            &wgpu::DeviceDescriptor {
137                label: Some("kopitiam-gpu device"),
138                required_features: wgpu::Features::empty(),
139                // downlevel_defaults so mobile/GL adapters qualify too.
140                required_limits: wgpu::Limits::downlevel_defaults(),
141                // No experimental wgpu features requested.
142                experimental_features: wgpu::ExperimentalFeatures::default(),
143                memory_hints: wgpu::MemoryHints::Performance,
144                // No API-trace capture (a debugging feature); off in production.
145                trace: wgpu::Trace::Off,
146            },
147        ))
148        .map_err(|e| GpuUnavailable::NoDevice(e.to_string()))?;
149
150        let ctx = Self {
151            device,
152            queue,
153            info,
154        };
155
156        // Log once, at init, so the maintainer can SEE on the tablet which GPU
157        // (and backend) engaged. Goes to stderr so it never pollutes a command's
158        // stdout data. The CPU-fallback counterpart is logged by Executor::new.
159        eprintln!("[kopitiam-gpu] {}", ctx.describe_backend());
160
161        Ok(ctx)
162    }
163
164    /// The logical device (buffer/pipeline/shader creation goes through this).
165    pub fn device(&self) -> &wgpu::Device {
166        &self.device
167    }
168
169    /// The command queue (submit encoded command buffers here).
170    pub fn queue(&self) -> &wgpu::Queue {
171        &self.queue
172    }
173
174    /// The selected adapter's identity: name, backend, device type, driver.
175    ///
176    /// This is the raw structured form; [`describe_backend`](Self::describe_backend)
177    /// formats it into one human line. Use this to verify on-device that the GPU
178    /// engaged and via which backend (Vulkan vs GL).
179    pub fn adapter_info(&self) -> &wgpu::AdapterInfo {
180        &self.info
181    }
182
183    /// A one-line human description of what we're running on, e.g.
184    /// `GPU: Turnip Adreno (TM) 730 [Vulkan] (driver: Mesa 24.x, Turnip)`.
185    ///
186    /// This is the string logged at init and the one the README's Termux recipe
187    /// tells the maintainer to look for — if they see a GPU/Vulkan line here, the
188    /// device GPU engaged; if they see the `Executor`'s CPU-fallback line
189    /// instead, it didn't.
190    pub fn describe_backend(&self) -> String {
191        let i = &self.info;
192        // `Backend`/`DeviceType` are Debug, not guaranteed Display across wgpu
193        // versions, so format via Debug for stability. driver/driver_info can be
194        // empty on some backends; show them only when present.
195        let driver = if i.driver.is_empty() && i.driver_info.is_empty() {
196            String::new()
197        } else {
198            format!(" (driver: {} {})", i.driver, i.driver_info)
199        };
200        format!(
201            "GPU: {} [{:?}] {:?}{}",
202            i.name, i.backend, i.device_type, driver
203        )
204    }
205}