|
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 |
+ |
}
|