//! What drive a benchmark path actually sits on. //! //! Import and decode are I/O bound, so a run's numbers belong to a drive as //! much as to the code. Two runs of the same commit against the internal NVMe //! and against the exFAT test drive differ by more than any optimisation is //! likely to buy, and nothing in the output distinguished them: a saved //! baseline recorded throughput without recording what produced it. //! //! This records the conditions alongside the numbers so runs can be compared //! across drives, and so a comparison across *different* drives is visible as //! such rather than read as a regression. //! //! Linux only. Every field is optional and a missing one means "could not //! determine", never a default value. use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; /// The backing store for one path. pub(crate) struct Storage { pub(crate) path: PathBuf, mount_point: Option, fs_type: Option, source: Option, /// Whole-disk kernel name (`sda`), not the partition (`sda1`): the /// queue and device attributes hang off the disk. disk: Option, model: Option, rotational: Option, /// USB link speed in Mb/s, when the disk is behind USB. The distinction /// between 10000 and 480 here is the difference between a real /// measurement and a meaningless one, and a bad cable silently causes it. usb_speed_mbps: Option, } impl Storage { /// One line, for the printed conditions block. pub(crate) fn summary(&self) -> String { let mut parts: Vec = Vec::new(); if let Some(fs) = &self.fs_type { parts.push(fs.clone()); } if let Some(src) = &self.source { parts.push(format!("on {src}")); } if let Some(model) = &self.model { parts.push(format!("({model})")); } match self.rotational { Some(true) => parts.push("rotational".into()), Some(false) => parts.push("solid-state".into()), None => {} } if let Some(mbps) = self.usb_speed_mbps { parts.push(format!("USB {mbps} Mb/s")); } if parts.is_empty() { "unknown".into() } else { parts.join(", ") } } /// True when this path is behind a USB link slower than 5 Gb/s. /// /// An Apple USB-C cable negotiates USB 2.0 on this drive, which turns /// every throughput number into a measurement of the cable. pub(crate) fn is_slow_usb(&self) -> bool { self.usb_speed_mbps.is_some_and(|s| s < 5000) } pub(crate) fn to_json(&self) -> Value { let mut m = Map::new(); m.insert("path".into(), self.path.display().to_string().into()); insert_opt(&mut m, "mount_point", self.mount_point.clone()); insert_opt(&mut m, "fs_type", self.fs_type.clone()); insert_opt(&mut m, "source", self.source.clone()); insert_opt(&mut m, "disk", self.disk.clone()); insert_opt(&mut m, "model", self.model.clone()); if let Some(r) = self.rotational { m.insert("rotational".into(), r.into()); } if let Some(s) = self.usb_speed_mbps { m.insert("usb_speed_mbps".into(), s.into()); } Value::Object(m) } } fn insert_opt(m: &mut Map, key: &str, val: Option) { if let Some(v) = val { m.insert(key.into(), v.into()); } } /// Describe the drive backing `path`. /// /// Never fails: an undeterminable field is left unset rather than guessed, so /// a report from a machine this cannot read is still a valid report. pub(crate) fn describe(path: &Path) -> Storage { let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let mut storage = Storage { path: canonical.clone(), mount_point: None, fs_type: None, source: None, disk: None, model: None, rotational: None, usb_speed_mbps: None, }; let Some(mount) = find_mount(&canonical) else { return storage; }; storage.mount_point = Some(mount.point); storage.fs_type = Some(mount.fs_type); storage.source = Some(mount.source); if let Some(disk) = disk_for_dev(&mount.dev) { storage.rotational = read_trimmed(&format!("/sys/block/{disk}/queue/rotational")) .and_then(|v| v.parse::().ok()) .map(|v| v == 1); storage.model = read_trimmed(&format!("/sys/block/{disk}/device/model")); storage.usb_speed_mbps = usb_speed_for_disk(&disk); storage.disk = Some(disk); } storage } struct Mount { point: String, fs_type: String, source: String, /// `major:minor`, which is how /sys/dev/block is keyed. dev: String, } /// Find the mount entry whose mount point is the longest prefix of `path`. /// /// Longest wins because mount points nest: /media/max/T9 sits under /, and the /// shorter match would attribute the external drive's numbers to the root /// filesystem. fn find_mount(path: &Path) -> Option { let text = std::fs::read_to_string("/proc/self/mountinfo").ok()?; let mut best: Option = None; let mut best_len = 0usize; for line in text.lines() { // Optional fields sit between the mount options and a "-" separator, // so the tail cannot be indexed from the start of the line. let (head, tail) = line.split_once(" - ")?; let head: Vec<&str> = head.split_whitespace().collect(); let tail: Vec<&str> = tail.split_whitespace().collect(); if head.len() < 5 || tail.len() < 2 { continue; } let point = unescape(head[4]); if !path.starts_with(&point) { continue; } if point.len() < best_len { continue; } best_len = point.len(); best = Some(Mount { point, fs_type: tail[0].to_string(), source: unescape(tail[1]), dev: head[2].to_string(), }); } best } /// mountinfo octal-escapes space, tab, newline and backslash in paths. fn unescape(s: &str) -> String { if !s.contains('\\') { return s.to_string(); } let mut out = String::with_capacity(s.len()); let bytes = s.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'\\' && i + 3 < bytes.len() { let oct = &s[i + 1..i + 4]; if let Ok(v) = u8::from_str_radix(oct, 8) { out.push(v as char); i += 4; continue; } } out.push(bytes[i] as char); i += 1; } out } /// Map `major:minor` to the whole-disk kernel name. /// /// /sys/dev/block/: resolves to the partition; the queue and device /// attributes live on its parent disk, so a partition walks up one level. fn disk_for_dev(dev: &str) -> Option { let link = std::fs::read_link(format!("/sys/dev/block/{dev}")).ok()?; let name = link.file_name()?.to_string_lossy().to_string(); // A partition directory contains "partition"; a whole disk does not. let resolved = format!("/sys/dev/block/{dev}"); if Path::new(&format!("{resolved}/partition")).exists() { let parent = link.parent()?.file_name()?.to_string_lossy().to_string(); return Some(parent); } Some(name) } /// USB link speed in Mb/s for a disk, or None when it is not behind USB. /// /// Walks up the sysfs device chain to the first ancestor that looks like a USB /// device (has both `speed` and `idVendor`). Intermediate nodes such as the /// SCSI host have no speed, and the USB interface node has no idVendor. fn usb_speed_for_disk(disk: &str) -> Option { let link = std::fs::read_link(format!("/sys/block/{disk}")).ok()?; let mut dir = Path::new("/sys/block").join(link).canonicalize().ok()?; loop { if dir.join("idVendor").exists() && let Some(speed) = read_trimmed(&dir.join("speed").display().to_string()) && let Ok(v) = speed.parse::() { return Some(v.round() as u32); } dir = dir.parent()?.to_path_buf(); if dir == Path::new("/sys") || dir == Path::new("/") { return None; } } } /// Print the conditions block that heads every mode's output. /// /// `corpus_bytes` is what the run will actually read, when the caller already /// knows it. It drives the page-cache warning, which matters more than it /// looks: a corpus that fits in RAM is served from memory on the second run, /// so two drives measured back to back can look identical no matter how far /// apart they really are. pub(crate) fn print_conditions(roles: &[(&str, &Storage)], corpus_bytes: Option) { println!("━━━ CONDITIONS ━━━"); println!(); for (role, storage) in roles { println!(" {:<8} {}", role, storage.path.display()); println!(" {:<8} {}", "", storage.summary()); if storage.is_slow_usb() { println!( " {:<8} WARNING: this link is USB 2.0 speed. Every timing here is a", "" ); println!( " {:<8} measurement of the cable. Replace it before recording.", "" ); } } if let Some(mem) = mem_total_mb() { println!(" {:<8} {:.0} MB RAM", "memory", mem); if let Some(bytes) = corpus_bytes { let corpus_mb = bytes as f64 / 1e6; if corpus_mb < mem * 0.5 { println!( " {:<8} corpus is {:.0} MB against {:.0} MB RAM, so a repeat run reads", "", corpus_mb, mem ); println!( " {:<8} from page cache rather than the drive. Compare drives cold.", "" ); } } } println!(); } fn read_trimmed(path: &str) -> Option { std::fs::read_to_string(path) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } /// Total system RAM in MB. /// /// Recorded because it decides whether a corpus fits in page cache, which is /// the difference between timing a drive and timing memory. pub(crate) fn mem_total_mb() -> Option { let text = std::fs::read_to_string("/proc/meminfo").ok()?; let line = text.lines().find(|l| l.starts_with("MemTotal:"))?; let kb: f64 = line.split_whitespace().nth(1)?.parse().ok()?; Some(kb / 1024.0) } #[cfg(test)] mod tests { use super::*; #[test] fn unescapes_octal_paths() { assert_eq!(unescape("/media/max/My\\040Drive"), "/media/max/My Drive"); assert_eq!(unescape("/plain/path"), "/plain/path"); } #[test] fn describes_a_real_path_without_panicking() { // Root always exists and is always mounted, so the mount lookup must // succeed here. A silent None would mean the mountinfo parse broke, // which is exactly the failure this test exists to catch. let s = describe(Path::new("/")); assert!(s.fs_type.is_some(), "no filesystem type for /"); assert!(s.mount_point.is_some(), "no mount point for /"); assert_ne!(s.summary(), "unknown"); } }