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}