Skip to main content

max / audiofiles

11.1 KB · 321 lines History Blame Raw
1 //! What drive a benchmark path actually sits on.
2 //!
3 //! Import and decode are I/O bound, so a run's numbers belong to a drive as
4 //! much as to the code. Two runs of the same commit against the internal NVMe
5 //! and against the exFAT test drive differ by more than any optimisation is
6 //! likely to buy, and nothing in the output distinguished them: a saved
7 //! baseline recorded throughput without recording what produced it.
8 //!
9 //! This records the conditions alongside the numbers so runs can be compared
10 //! across drives, and so a comparison across *different* drives is visible as
11 //! such rather than read as a regression.
12 //!
13 //! Linux only. Every field is optional and a missing one means "could not
14 //! determine", never a default value.
15
16 use std::path::{Path, PathBuf};
17
18 use serde_json::{Map, Value};
19
20 /// The backing store for one path.
21 pub(crate) struct Storage {
22 pub(crate) path: PathBuf,
23 mount_point: Option<String>,
24 fs_type: Option<String>,
25 source: Option<String>,
26 /// Whole-disk kernel name (`sda`), not the partition (`sda1`): the
27 /// queue and device attributes hang off the disk.
28 disk: Option<String>,
29 model: Option<String>,
30 rotational: Option<bool>,
31 /// USB link speed in Mb/s, when the disk is behind USB. The distinction
32 /// between 10000 and 480 here is the difference between a real
33 /// measurement and a meaningless one, and a bad cable silently causes it.
34 usb_speed_mbps: Option<u32>,
35 }
36
37 impl Storage {
38 /// One line, for the printed conditions block.
39 pub(crate) fn summary(&self) -> String {
40 let mut parts: Vec<String> = Vec::new();
41 if let Some(fs) = &self.fs_type {
42 parts.push(fs.clone());
43 }
44 if let Some(src) = &self.source {
45 parts.push(format!("on {src}"));
46 }
47 if let Some(model) = &self.model {
48 parts.push(format!("({model})"));
49 }
50 match self.rotational {
51 Some(true) => parts.push("rotational".into()),
52 Some(false) => parts.push("solid-state".into()),
53 None => {}
54 }
55 if let Some(mbps) = self.usb_speed_mbps {
56 parts.push(format!("USB {mbps} Mb/s"));
57 }
58 if parts.is_empty() {
59 "unknown".into()
60 } else {
61 parts.join(", ")
62 }
63 }
64
65 /// True when this path is behind a USB link slower than 5 Gb/s.
66 ///
67 /// An Apple USB-C cable negotiates USB 2.0 on this drive, which turns
68 /// every throughput number into a measurement of the cable.
69 pub(crate) fn is_slow_usb(&self) -> bool {
70 self.usb_speed_mbps.is_some_and(|s| s < 5000)
71 }
72
73 pub(crate) fn to_json(&self) -> Value {
74 let mut m = Map::new();
75 m.insert("path".into(), self.path.display().to_string().into());
76 insert_opt(&mut m, "mount_point", self.mount_point.clone());
77 insert_opt(&mut m, "fs_type", self.fs_type.clone());
78 insert_opt(&mut m, "source", self.source.clone());
79 insert_opt(&mut m, "disk", self.disk.clone());
80 insert_opt(&mut m, "model", self.model.clone());
81 if let Some(r) = self.rotational {
82 m.insert("rotational".into(), r.into());
83 }
84 if let Some(s) = self.usb_speed_mbps {
85 m.insert("usb_speed_mbps".into(), s.into());
86 }
87 Value::Object(m)
88 }
89 }
90
91 fn insert_opt(m: &mut Map<String, Value>, key: &str, val: Option<String>) {
92 if let Some(v) = val {
93 m.insert(key.into(), v.into());
94 }
95 }
96
97 /// Describe the drive backing `path`.
98 ///
99 /// Never fails: an undeterminable field is left unset rather than guessed, so
100 /// a report from a machine this cannot read is still a valid report.
101 pub(crate) fn describe(path: &Path) -> Storage {
102 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
103 let mut storage = Storage {
104 path: canonical.clone(),
105 mount_point: None,
106 fs_type: None,
107 source: None,
108 disk: None,
109 model: None,
110 rotational: None,
111 usb_speed_mbps: None,
112 };
113
114 let Some(mount) = find_mount(&canonical) else {
115 return storage;
116 };
117 storage.mount_point = Some(mount.point);
118 storage.fs_type = Some(mount.fs_type);
119 storage.source = Some(mount.source);
120
121 if let Some(disk) = disk_for_dev(&mount.dev) {
122 storage.rotational = read_trimmed(&format!("/sys/block/{disk}/queue/rotational"))
123 .and_then(|v| v.parse::<u8>().ok())
124 .map(|v| v == 1);
125 storage.model = read_trimmed(&format!("/sys/block/{disk}/device/model"));
126 storage.usb_speed_mbps = usb_speed_for_disk(&disk);
127 storage.disk = Some(disk);
128 }
129
130 storage
131 }
132
133 struct Mount {
134 point: String,
135 fs_type: String,
136 source: String,
137 /// `major:minor`, which is how /sys/dev/block is keyed.
138 dev: String,
139 }
140
141 /// Find the mount entry whose mount point is the longest prefix of `path`.
142 ///
143 /// Longest wins because mount points nest: /media/max/T9 sits under /, and the
144 /// shorter match would attribute the external drive's numbers to the root
145 /// filesystem.
146 fn find_mount(path: &Path) -> Option<Mount> {
147 let text = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
148 let mut best: Option<Mount> = None;
149 let mut best_len = 0usize;
150
151 for line in text.lines() {
152 // Optional fields sit between the mount options and a "-" separator,
153 // so the tail cannot be indexed from the start of the line.
154 let (head, tail) = line.split_once(" - ")?;
155 let head: Vec<&str> = head.split_whitespace().collect();
156 let tail: Vec<&str> = tail.split_whitespace().collect();
157 if head.len() < 5 || tail.len() < 2 {
158 continue;
159 }
160 let point = unescape(head[4]);
161 if !path.starts_with(&point) {
162 continue;
163 }
164 if point.len() < best_len {
165 continue;
166 }
167 best_len = point.len();
168 best = Some(Mount {
169 point,
170 fs_type: tail[0].to_string(),
171 source: unescape(tail[1]),
172 dev: head[2].to_string(),
173 });
174 }
175 best
176 }
177
178 /// mountinfo octal-escapes space, tab, newline and backslash in paths.
179 fn unescape(s: &str) -> String {
180 if !s.contains('\\') {
181 return s.to_string();
182 }
183 let mut out = String::with_capacity(s.len());
184 let bytes = s.as_bytes();
185 let mut i = 0;
186 while i < bytes.len() {
187 if bytes[i] == b'\\' && i + 3 < bytes.len() {
188 let oct = &s[i + 1..i + 4];
189 if let Ok(v) = u8::from_str_radix(oct, 8) {
190 out.push(v as char);
191 i += 4;
192 continue;
193 }
194 }
195 out.push(bytes[i] as char);
196 i += 1;
197 }
198 out
199 }
200
201 /// Map `major:minor` to the whole-disk kernel name.
202 ///
203 /// /sys/dev/block/<maj>:<min> resolves to the partition; the queue and device
204 /// attributes live on its parent disk, so a partition walks up one level.
205 fn disk_for_dev(dev: &str) -> Option<String> {
206 let link = std::fs::read_link(format!("/sys/dev/block/{dev}")).ok()?;
207 let name = link.file_name()?.to_string_lossy().to_string();
208 // A partition directory contains "partition"; a whole disk does not.
209 let resolved = format!("/sys/dev/block/{dev}");
210 if Path::new(&format!("{resolved}/partition")).exists() {
211 let parent = link.parent()?.file_name()?.to_string_lossy().to_string();
212 return Some(parent);
213 }
214 Some(name)
215 }
216
217 /// USB link speed in Mb/s for a disk, or None when it is not behind USB.
218 ///
219 /// Walks up the sysfs device chain to the first ancestor that looks like a USB
220 /// device (has both `speed` and `idVendor`). Intermediate nodes such as the
221 /// SCSI host have no speed, and the USB interface node has no idVendor.
222 fn usb_speed_for_disk(disk: &str) -> Option<u32> {
223 let link = std::fs::read_link(format!("/sys/block/{disk}")).ok()?;
224 let mut dir = Path::new("/sys/block").join(link).canonicalize().ok()?;
225 loop {
226 if dir.join("idVendor").exists()
227 && let Some(speed) = read_trimmed(&dir.join("speed").display().to_string())
228 && let Ok(v) = speed.parse::<f64>()
229 {
230 return Some(v.round() as u32);
231 }
232 dir = dir.parent()?.to_path_buf();
233 if dir == Path::new("/sys") || dir == Path::new("/") {
234 return None;
235 }
236 }
237 }
238
239 /// Print the conditions block that heads every mode's output.
240 ///
241 /// `corpus_bytes` is what the run will actually read, when the caller already
242 /// knows it. It drives the page-cache warning, which matters more than it
243 /// looks: a corpus that fits in RAM is served from memory on the second run,
244 /// so two drives measured back to back can look identical no matter how far
245 /// apart they really are.
246 pub(crate) fn print_conditions(roles: &[(&str, &Storage)], corpus_bytes: Option<u64>) {
247 println!("━━━ CONDITIONS ━━━");
248 println!();
249 for (role, storage) in roles {
250 println!(" {:<8} {}", role, storage.path.display());
251 println!(" {:<8} {}", "", storage.summary());
252 if storage.is_slow_usb() {
253 println!(
254 " {:<8} WARNING: this link is USB 2.0 speed. Every timing here is a",
255 ""
256 );
257 println!(
258 " {:<8} measurement of the cable. Replace it before recording.",
259 ""
260 );
261 }
262 }
263 if let Some(mem) = mem_total_mb() {
264 println!(" {:<8} {:.0} MB RAM", "memory", mem);
265 if let Some(bytes) = corpus_bytes {
266 let corpus_mb = bytes as f64 / 1e6;
267 if corpus_mb < mem * 0.5 {
268 println!(
269 " {:<8} corpus is {:.0} MB against {:.0} MB RAM, so a repeat run reads",
270 "", corpus_mb, mem
271 );
272 println!(
273 " {:<8} from page cache rather than the drive. Compare drives cold.",
274 ""
275 );
276 }
277 }
278 }
279 println!();
280 }
281
282 fn read_trimmed(path: &str) -> Option<String> {
283 std::fs::read_to_string(path)
284 .ok()
285 .map(|s| s.trim().to_string())
286 .filter(|s| !s.is_empty())
287 }
288
289 /// Total system RAM in MB.
290 ///
291 /// Recorded because it decides whether a corpus fits in page cache, which is
292 /// the difference between timing a drive and timing memory.
293 pub(crate) fn mem_total_mb() -> Option<f64> {
294 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
295 let line = text.lines().find(|l| l.starts_with("MemTotal:"))?;
296 let kb: f64 = line.split_whitespace().nth(1)?.parse().ok()?;
297 Some(kb / 1024.0)
298 }
299
300 #[cfg(test)]
301 mod tests {
302 use super::*;
303
304 #[test]
305 fn unescapes_octal_paths() {
306 assert_eq!(unescape("/media/max/My\\040Drive"), "/media/max/My Drive");
307 assert_eq!(unescape("/plain/path"), "/plain/path");
308 }
309
310 #[test]
311 fn describes_a_real_path_without_panicking() {
312 // Root always exists and is always mounted, so the mount lookup must
313 // succeed here. A silent None would mean the mountinfo parse broke,
314 // which is exactly the failure this test exists to catch.
315 let s = describe(Path::new("/"));
316 assert!(s.fs_type.is_some(), "no filesystem type for /");
317 assert!(s.mount_point.is_some(), "no mount point for /");
318 assert_ne!(s.summary(), "unknown");
319 }
320 }
321