Line | Count | Source |
1 | | // SPDX-License-Identifier: Apache-2.0 |
2 | | //! Vulkan runtime detection. |
3 | | //! |
4 | | //! Vulkan advertises itself through two filesystem facts that need no linking: |
5 | | //! the loader (`libvulkan.so.1`) and the ICD manifests each installed driver |
6 | | //! drops under `/usr/share/vulkan/icd.d`. Reading those is enough to answer |
7 | | //! "can this host run a Vulkan build, and to which API version", which is all |
8 | | //! a consumer selecting a prebuilt artifact needs. |
9 | | //! |
10 | | //! What a manifest yields is that driver's *advertised* version, and `host()` |
11 | | //! reports the highest across them. That is neither the loader's instance |
12 | | //! version — the number `vulkaninfo` prints, usually the higher of the two — |
13 | | //! nor any single device's `apiVersion`; both of those require creating an |
14 | | //! instance and calling into the loader. Staying on the filesystem is the |
15 | | //! trade this module makes, and the reason a caller must not read the result |
16 | | //! as a per-GPU capability. |
17 | | //! |
18 | | //! Unlike CUDA and `ROCm` there is no architecture to report: SPIR-V is |
19 | | //! portable and the driver compiles it at load time, so a Vulkan build that |
20 | | //! runs anywhere runs everywhere the loader does. |
21 | | |
22 | | use crate::{VulkanHost, VulkanVersion}; |
23 | | use std::path::Path; |
24 | | |
25 | | /// Where the loader looks for driver manifests. Every manifest under both |
26 | | /// directories is read; `host()` takes the highest `api_version` across the |
27 | | /// union, so this order does not affect which version wins. |
28 | | const ICD_DIRS: [&str; 2] = ["/usr/local/share/vulkan/icd.d", "/usr/share/vulkan/icd.d"]; |
29 | | |
30 | | /// Loader sonames to probe. Checked with `.any()`, so this is an unordered |
31 | | /// set, not a preference list. |
32 | | const LOADER_SONAMES: [&str; 2] = ["libvulkan.so.1", "libvulkan.so"]; |
33 | | |
34 | | /// Directories the loader is normally installed into. |
35 | | const LIB_DIRS: [&str; 4] = [ |
36 | | "/usr/lib/x86_64-linux-gnu", |
37 | | "/usr/lib64", |
38 | | "/usr/lib", |
39 | | "/usr/local/lib", |
40 | | ]; |
41 | | |
42 | | /// Pull `ICD.api_version` out of a driver manifest. |
43 | | /// |
44 | | /// The manifest is small, fixed-shape JSON, so this reads the one field it |
45 | | /// needs rather than modelling the whole document. |
46 | 28 | fn parse_icd_api_version(content: &str) -> Option<VulkanVersion> { |
47 | 28 | let value24 : serde_json::Value24 = serde_json::from_str(content).ok()?4 ; |
48 | 24 | let text17 = value.get("ICD")?2 .get22 ("api_version")?4 .as_str18 ()?1 ; |
49 | 17 | let (major13 , minor13 , patch13 ) = crate::parse_dotted_version(text)?4 ; |
50 | 13 | Some(VulkanVersion { |
51 | 13 | major, |
52 | 13 | minor, |
53 | 13 | patch, |
54 | 13 | }) |
55 | 28 | } |
56 | | |
57 | | /// Whether the Vulkan loader is installed under any of `dirs`. Presence of the |
58 | | /// shared object is the signal; nothing is opened or linked. |
59 | | /// |
60 | | /// Only the sonames a program is linked against count: a bare |
61 | | /// `libvulkan.so.1.3.280` with no `libvulkan.so.1` beside it is a file the |
62 | | /// loader could not be found by, so it is not an install. |
63 | | /// |
64 | | /// Takes its directories rather than reading `LIB_DIRS` directly, the way the |
65 | | /// `ROCm` probe takes an install root, so the search is testable against a |
66 | | /// fixture instead of whatever the host happens to have. |
67 | 228 | fn loader_in<P: AsRef<Path>>(dirs: &[P]) -> bool { |
68 | 228 | dirs.iter() |
69 | 235 | .flat_map228 (|dir| LOADER_SONAMES228 .iter228 ().map228 (move |so| dir.as_ref().join(so))) |
70 | 235 | .any228 (|path| path.exists()) |
71 | 228 | } |
72 | | |
73 | | /// The highest API version any manifest under `dirs` advertises. |
74 | | /// |
75 | | /// Highest rather than lowest: a host with both a software rasterizer and a |
76 | | /// real driver can run what the real driver supports, and the consumer is |
77 | | /// choosing one build for the machine. |
78 | | /// |
79 | | /// Unreadable directories, non-`.json` files, and manifests that do not parse |
80 | | /// are skipped rather than failing the probe — a stray file in `icd.d` must |
81 | | /// not hide a working driver's manifest. |
82 | 226 | fn highest_api_version<P: AsRef<Path>>(dirs: &[P]) -> Option<VulkanVersion> { |
83 | 226 | dirs.iter() |
84 | 443 | .filter_map226 (|dir| std::fs::read_dir(dir).ok()) |
85 | 226 | .flatten() |
86 | 226 | .flatten() |
87 | 226 | .filter(|entry| entry.path().extension()16 .is_some_and16 (|e| e15 == "json"15 )) |
88 | 226 | .filter_map(|entry| std::fs::read_to_string12 (entry12 .path12 ()).ok12 ()) |
89 | 226 | .filter_map(|content| parse_icd_api_version12 (&content12 )) |
90 | 226 | .max() |
91 | 226 | } |
92 | | |
93 | | /// The runtime installed under `lib_dirs`/`icd_dirs`, if both halves are there. |
94 | | /// |
95 | | /// Both are required: the loader alone cannot name a version, and manifests |
96 | | /// alone describe drivers nothing can dispatch to. |
97 | 219 | fn host_in<L: AsRef<Path>, I: AsRef<Path>>(lib_dirs: &[L], icd_dirs: &[I]) -> Option<VulkanHost> { |
98 | 219 | if !loader_in(lib_dirs) { |
99 | 1 | return None; |
100 | 218 | } |
101 | 218 | let api_version1 = highest_api_version(icd_dirs)?217 ; |
102 | 1 | Some(VulkanHost { api_version }) |
103 | 219 | } |
104 | | |
105 | | /// Probe the conventional locations. |
106 | | /// |
107 | | /// Unconditional, like the `ROCm` and `oneAPI` probes: the paths this checks |
108 | | /// are Linux-specific and simply do not exist on other platforms, so the |
109 | | /// lookups come back empty there without needing a `cfg`. |
110 | 216 | pub(crate) fn host() -> Option<VulkanHost> { |
111 | 216 | host_in(&LIB_DIRS, &ICD_DIRS) |
112 | 216 | } |
113 | | |
114 | | #[cfg(test)] |
115 | | mod tests { |
116 | | use super::*; |
117 | | use std::path::PathBuf; |
118 | | use std::sync::atomic::{AtomicUsize, Ordering}; |
119 | | |
120 | | /// A real `radeon_icd.x86_64.json`, trimmed to the fields read here. |
121 | | const RADEON: &str = r#"{"file_format_version":"1.0.0", |
122 | | "ICD":{"library_path":"/usr/lib/libvulkan_radeon.so", |
123 | | "api_version":"1.3.280"}}"#; |
124 | | |
125 | | /// Mesa's software rasterizer, which ships by default on many distros and |
126 | | /// is an ICD like any other — the case the `README` warns consumers about. |
127 | | const LAVAPIPE: &str = r#"{"file_format_version":"1.0.1", |
128 | | "ICD":{"library_path":"/usr/lib/libvulkan_lvp.so", |
129 | | "api_version":"1.3.255"}}"#; |
130 | | |
131 | | /// A scratch directory that deletes itself on drop. |
132 | | /// |
133 | | /// Written here rather than pulled in as a dev-dependency: this probe is a |
134 | | /// filesystem read, so testing the search means giving it real directories, |
135 | | /// and that needs nothing beyond `std`. |
136 | | struct TempTree(PathBuf); |
137 | | |
138 | | impl TempTree { |
139 | | /// A fresh empty directory. The pid plus a counter keeps concurrent |
140 | | /// tests — and concurrent `cargo test` runs — off each other's fixtures. |
141 | 15 | fn new(label: &str) -> Self { |
142 | | static COUNTER: AtomicUsize = AtomicUsize::new(0); |
143 | 15 | let nth = COUNTER.fetch_add(1, Ordering::Relaxed); |
144 | 15 | let path = std::env::temp_dir().join(format!( |
145 | 15 | "gpu-probe-vulkan-{}-{label}-{nth}", |
146 | 15 | std::process::id() |
147 | 15 | )); |
148 | 15 | let _ = std::fs::remove_dir_all(&path); |
149 | 15 | std::fs::create_dir_all(&path).expect("scratch directory is creatable"); |
150 | 15 | Self(path) |
151 | 15 | } |
152 | | |
153 | | /// Drop a file into the tree; chainable, so a fixture reads as a listing. |
154 | 19 | fn with(&self, name: &str, content: &str) -> &Self { |
155 | 19 | std::fs::write(self.0.join(name), content).expect("fixture file is writable"); |
156 | 19 | self |
157 | 19 | } |
158 | | |
159 | 23 | fn path(&self) -> &Path { |
160 | 23 | &self.0 |
161 | 23 | } |
162 | | } |
163 | | |
164 | | impl Drop for TempTree { |
165 | 15 | fn drop(&mut self) { |
166 | 15 | let _ = std::fs::remove_dir_all(&self.0); |
167 | 15 | } |
168 | | } |
169 | | |
170 | | #[test] |
171 | 1 | fn parses_an_icd_manifest_api_version() { |
172 | 1 | let icd = r#"{"file_format_version":"1.0.0", |
173 | 1 | "ICD":{"library_path":"libvulkan_radeon.so", |
174 | 1 | "api_version":"1.3.280"}}"#; |
175 | 1 | assert_eq!( |
176 | 1 | parse_icd_api_version(icd), |
177 | 1 | Some(VulkanVersion::new(1, 3, 280)) |
178 | | ); |
179 | 1 | assert_eq!( |
180 | 1 | parse_icd_api_version(LAVAPIPE), |
181 | 1 | Some(VulkanVersion::new(1, 3, 255)) |
182 | | ); |
183 | | // NVIDIA's manifest carries extra top-level keys; unknown fields are |
184 | | // ignored rather than rejected. |
185 | 1 | let nvidia = r#"{"file_format_version":"1.0.1", |
186 | 1 | "ICD":{"library_path":"libGLX_nvidia.so.0", |
187 | 1 | "api_version":"1.3.277", |
188 | 1 | "is_portability_driver":false}}"#; |
189 | 1 | assert_eq!( |
190 | 1 | parse_icd_api_version(nvidia), |
191 | 1 | Some(VulkanVersion::new(1, 3, 277)) |
192 | | ); |
193 | 1 | } |
194 | | |
195 | | #[test] |
196 | 1 | fn rejects_an_icd_manifest_without_an_api_version() { |
197 | 1 | let icd = r#"{"ICD":{"library_path":"libvulkan_radeon.so"}}"#; |
198 | 1 | assert_eq!(parse_icd_api_version(icd), None); |
199 | 1 | assert_eq!(parse_icd_api_version("not json"), None); |
200 | 1 | assert_eq!(parse_icd_api_version(""), None); |
201 | 1 | assert_eq!(parse_icd_api_version("{}"), None); |
202 | | // The version must be under `ICD`, and must be a string: a JSON number |
203 | | // would parse as 1.3 and silently lose the patch. |
204 | 1 | assert_eq!(parse_icd_api_version(r#"{"api_version":"1.3.280"}"#), None); |
205 | 1 | assert_eq!( |
206 | 1 | parse_icd_api_version(r#"{"ICD":{"api_version":1.3}}"#), |
207 | | None |
208 | | ); |
209 | 1 | assert_eq!(parse_icd_api_version(r#"{"ICD":"1.3.280"}"#), None); |
210 | 1 | assert_eq!( |
211 | 1 | parse_icd_api_version(r#"{"ICD":[{"api_version":"1.3.0"}]}"#), |
212 | | None |
213 | | ); |
214 | 1 | } |
215 | | |
216 | | #[test] |
217 | 1 | fn rejects_manifests_whose_version_is_not_a_version() { |
218 | 5 | let with1 = |version: &str| { |
219 | 5 | parse_icd_api_version(&format!(r#"{{"ICD":{{"api_version":"{version}"}}}}"#)) |
220 | 5 | }; |
221 | | // A two-part version is a legal shape for the shared parser; the spec |
222 | | // writes all three, but a truncated one still names an API level. |
223 | 1 | assert_eq!(with("1.2"), Some(VulkanVersion::new(1, 2, 0))); |
224 | 1 | assert_eq!(with("1"), None, "a bare major is not a version"); |
225 | 1 | assert_eq!(with(""), None); |
226 | 1 | assert_eq!(with("one.three.zero"), None); |
227 | 1 | assert_eq!(with("1.3.x"), None); |
228 | 1 | } |
229 | | |
230 | | #[test] |
231 | 1 | fn versions_order_major_first() { |
232 | 1 | assert!(VulkanVersion::new(1, 3, 0) > VulkanVersion::new(1, 2, 300)); |
233 | 1 | assert!(VulkanVersion::new(2, 0, 0) > VulkanVersion::new(1, 9, 9)); |
234 | 1 | } |
235 | | |
236 | | #[test] |
237 | 1 | fn loader_is_found_under_either_soname() { |
238 | 1 | let versioned = TempTree::new("loader-soname-1"); |
239 | 1 | versioned.with("libvulkan.so.1", ""); |
240 | 1 | assert!(loader_in(&[versioned.path()])); |
241 | | |
242 | | // The development symlink alone is enough; the set is unordered. |
243 | 1 | let unversioned = TempTree::new("loader-soname-dev"); |
244 | 1 | unversioned.with("libvulkan.so", ""); |
245 | 1 | assert!(loader_in(&[unversioned.path()])); |
246 | | |
247 | | // Any one directory in the list satisfies the search. |
248 | 1 | let empty = TempTree::new("loader-empty-first"); |
249 | 1 | assert!(loader_in(&[empty.path(), versioned.path()])); |
250 | 1 | } |
251 | | |
252 | | #[test] |
253 | 1 | fn a_host_without_the_loader_reports_no_install() { |
254 | 1 | let empty = TempTree::new("loader-absent"); |
255 | 1 | assert!(!loader_in(&[empty.path()])); |
256 | 1 | assert!(!loader_in(&[Path::new("/nonexistent-vulkan-libdir")])); |
257 | 1 | assert!(!loader_in::<&Path>(&[])); |
258 | | |
259 | | // The real file with no soname symlink beside it: nothing can dlopen |
260 | | // `libvulkan.so.1` here, so this is not an installed loader. |
261 | 1 | let unlinked = TempTree::new("loader-unlinked"); |
262 | 1 | unlinked.with("libvulkan.so.1.3.280", ""); |
263 | 1 | assert!(!loader_in(&[unlinked.path()])); |
264 | 1 | } |
265 | | |
266 | | #[cfg(unix)] |
267 | | #[test] |
268 | 1 | fn a_dangling_loader_symlink_is_not_an_install() { |
269 | | // A package removed without its symlink leaves the name behind. The |
270 | | // check follows the link, so the broken one does not count. |
271 | 1 | let tree = TempTree::new("loader-dangling"); |
272 | 1 | std::os::unix::fs::symlink("libvulkan.so.1.3.280", tree.path().join("libvulkan.so.1")) |
273 | 1 | .expect("symlink is creatable"); |
274 | 1 | assert!(!loader_in(&[tree.path()])); |
275 | | |
276 | 1 | tree.with("libvulkan.so.1.3.280", ""); |
277 | 1 | assert!(loader_in(&[tree.path()]), "the same link now resolves"); |
278 | 1 | } |
279 | | |
280 | | #[test] |
281 | 1 | fn highest_api_version_wins_across_manifests() { |
282 | | // A machine with the software rasterizer installed alongside a real |
283 | | // driver: the real driver's level is the one a build can target. |
284 | 1 | let icd = TempTree::new("icd-highest"); |
285 | 1 | icd.with("lvp_icd.x86_64.json", LAVAPIPE) |
286 | 1 | .with("radeon_icd.x86_64.json", RADEON); |
287 | 1 | assert_eq!( |
288 | 1 | highest_api_version(&[icd.path()]), |
289 | 1 | Some(VulkanVersion::new(1, 3, 280)) |
290 | | ); |
291 | | |
292 | | // The two search directories are a union, not a preference order, so |
293 | | // the highest wins whichever side it sits on. |
294 | 1 | let local = TempTree::new("icd-local"); |
295 | 1 | local.with("lvp_icd.x86_64.json", LAVAPIPE); |
296 | 1 | let shared = TempTree::new("icd-shared"); |
297 | 1 | shared.with("radeon_icd.x86_64.json", RADEON); |
298 | 1 | assert_eq!( |
299 | 1 | highest_api_version(&[local.path(), shared.path()]), |
300 | 1 | Some(VulkanVersion::new(1, 3, 280)) |
301 | | ); |
302 | 1 | assert_eq!( |
303 | 1 | highest_api_version(&[shared.path(), local.path()]), |
304 | 1 | Some(VulkanVersion::new(1, 3, 280)), |
305 | | "directory order must not change the answer", |
306 | | ); |
307 | 1 | } |
308 | | |
309 | | #[test] |
310 | 1 | fn only_json_manifests_are_read() { |
311 | | // `icd.d` collects editor backups and disabled drivers; the loader |
312 | | // reads `.json` and so does this. |
313 | 1 | let icd = TempTree::new("icd-extensions"); |
314 | 1 | icd.with("lvp_icd.x86_64.json", LAVAPIPE) |
315 | 1 | .with("radeon_icd.x86_64.json.disabled", RADEON) |
316 | 1 | .with("radeon_icd.x86_64.json.bak", RADEON) |
317 | 1 | .with("notes.txt", RADEON) |
318 | 1 | .with("README", RADEON); |
319 | 1 | assert_eq!( |
320 | 1 | highest_api_version(&[icd.path()]), |
321 | 1 | Some(VulkanVersion::new(1, 3, 255)), |
322 | | "only the .json manifest counts, so lavapipe's version stands", |
323 | | ); |
324 | 1 | } |
325 | | |
326 | | #[test] |
327 | 1 | fn a_malformed_manifest_does_not_hide_a_good_one() { |
328 | 1 | let icd = TempTree::new("icd-malformed"); |
329 | 1 | icd.with("broken_icd.json", "{ not json") |
330 | 1 | .with("empty_icd.json", "") |
331 | 1 | .with("versionless_icd.json", r#"{"ICD":{"library_path":"x.so"}}"#) |
332 | 1 | .with("radeon_icd.x86_64.json", RADEON); |
333 | 1 | assert_eq!( |
334 | 1 | highest_api_version(&[icd.path()]), |
335 | 1 | Some(VulkanVersion::new(1, 3, 280)) |
336 | | ); |
337 | 1 | } |
338 | | |
339 | | #[test] |
340 | 1 | fn missing_or_empty_icd_directories_report_nothing() { |
341 | 1 | let empty = TempTree::new("icd-empty"); |
342 | 1 | assert_eq!(highest_api_version(&[empty.path()]), None); |
343 | 1 | assert_eq!( |
344 | 1 | highest_api_version(&[Path::new("/nonexistent-vulkan-icd-dir")]), |
345 | | None, |
346 | | "an absent directory is skipped, not an error", |
347 | | ); |
348 | 1 | assert_eq!(highest_api_version::<&Path>(&[]), None); |
349 | 1 | } |
350 | | |
351 | | #[test] |
352 | 1 | fn both_halves_are_required() { |
353 | 1 | let lib = TempTree::new("host-lib"); |
354 | 1 | lib.with("libvulkan.so.1", ""); |
355 | 1 | let icd = TempTree::new("host-icd"); |
356 | 1 | icd.with("radeon_icd.x86_64.json", RADEON); |
357 | 1 | let empty = TempTree::new("host-empty"); |
358 | | |
359 | 1 | assert_eq!( |
360 | 1 | host_in(&[lib.path()], &[icd.path()]), |
361 | 1 | Some(VulkanHost { |
362 | 1 | api_version: VulkanVersion::new(1, 3, 280) |
363 | 1 | }) |
364 | | ); |
365 | 1 | assert_eq!( |
366 | 1 | host_in(&[empty.path()], &[icd.path()]), |
367 | | None, |
368 | | "manifests describe drivers nothing can dispatch to without a loader", |
369 | | ); |
370 | 1 | assert_eq!( |
371 | 1 | host_in(&[lib.path()], &[empty.path()]), |
372 | | None, |
373 | | "a loader with no ICD advertises no version to report", |
374 | | ); |
375 | 1 | } |
376 | | |
377 | | #[test] |
378 | 1 | fn host_lookup_never_panics() { |
379 | | // Environment-dependent: asserts invariants, not the presence of a driver. |
380 | 1 | if let Some(v0 ) = host() { |
381 | 0 | assert!(v.api_version.major > 0); |
382 | 1 | } |
383 | 1 | } |
384 | | } |