diff --git a/README.md b/README.md index abe1bb3..62cb0a4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The procedure is [`spec/20-benchmarks.md`](https://github.com/tamnd/rupg/blob/ma ## Status -Early. The crate builds and CI is green, but no suite runs yet. `rupg-bench gates` prints the twelve gates of spec/02 section 2.10, `rupg-bench pins` prints the pinned versions, and `rupg-bench measure` runs the cgroup v2 runner. The first milestone, M0, builds the harness and measures every baseline that a later gate uses. Every command below is the planned interface from the spec. +Early. The crate builds and CI is green, but no suite runs yet. `rupg-bench gates` prints the twelve gates of spec/02 section 2.10, `rupg-bench pins` prints the pinned versions, `rupg-bench measure` runs the cgroup v2 runner, and `rupg-bench load` runs the load cases. The first milestone, M0, builds the harness and measures every baseline that a later gate uses. Every command below is the planned interface from the spec. ## The gates @@ -97,6 +97,13 @@ rupg-bench measure --name duckdb --cpus 0-15 --du hits.db -- duckdb hits.db -c " rupg-bench measure --unit postgresql@19-main --idle --du /var/lib/postgresql/19/main --json -- psql -c "SELECT 1" ``` +`rupg-bench load` runs the load cases of spec/20 section 20.3.1 and then times the load command in the same runner. For case A it drops the caches, checks with `fincore` that no page of the source is resident, times `cat > /dev/null` as the reference time `R`, drops the caches and checks again, and then times the load `L`. The result has `L`, `R` and `L / R`. For case B it reads the source twice, and `fincore` must report at least 99 percent of the pages resident. If fewer pages are resident, the run does the steps of case A and is recorded as case A. The load command must end with a checkpoint (spec/20 section 20.3.2). The command needs root, for `drop_caches`. + +```sh +rupg-bench load --case a --source hits.parquet --name duckdb-load --du hits.db -- \ + duckdb hits.db -c "CREATE TABLE hits AS SELECT * FROM read_parquet('hits.parquet'); CHECKPOINT;" +``` + ## The reporting rules 1. A claim has five parts: one named machine, one metric, a baseline that we measured, the same transport on both sides, and the ratio. A claim without all five is not made. diff --git a/src/args.rs b/src/args.rs index 25c6a72..d892121 100644 --- a/src/args.rs +++ b/src/args.rs @@ -40,6 +40,11 @@ impl Args { self.options.remove(i).1 } + /// True if `--name` has a value. It does not take the value. + pub(crate) fn value_is_set(&self, name: &str) -> bool { + self.options.iter().any(|(n, v)| n == name && v.is_some()) + } + /// Takes `--name` with no value. A flag that has a value is an error in `finish`. pub(crate) fn flag(&mut self, name: &str) -> bool { match self.options.iter().position(|(n, v)| n == name && v.is_none()) { diff --git a/src/cgroup.rs b/src/cgroup.rs index ed78edf..29dc341 100644 --- a/src/cgroup.rs +++ b/src/cgroup.rs @@ -14,7 +14,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, ExitStatus}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::{self, JoinHandle}; @@ -409,6 +409,26 @@ pub(crate) fn mib(bytes: u64) -> String { format!("{:.2} MiB ({bytes} bytes)", bytes as f64 / 1048576.0) } +/// Runs `argv` and measures `cg` while it runs. In a cgroup that the harness created, the command is the system and runs in the cgroup, and every process left in the cgroup is killed at the end. In a cgroup that exists, the command is the client and runs outside it. +pub(crate) fn run(cg: &Cgroup, argv: &[String]) -> Result<(ExitStatus, Usage), String> { + let mut cmd = if cg.owned() { + cg.command(argv)? + } else { + let (program, args) = argv.split_first().ok_or("no command to run")?; + let mut c = Command::new(program); + c.args(args); + c + }; + let interval = Interval::start(cg)?; + let status = cmd.status().map_err(|e| format!("{}: {e}", argv[0])); + let usage = interval.finish()?; + let status = status?; + if cg.owned() { + cg.kill_all()?; + } + Ok((status, usage)) +} + /// Measures the idle base: the cgroup with nothing to do for `duration`. pub(crate) fn idle_base(cg: &Cgroup, duration: Duration) -> Result { let interval = Interval::start(cg)?; diff --git a/src/load.rs b/src/load.rs new file mode 100644 index 0000000..71c06ef --- /dev/null +++ b/src/load.rs @@ -0,0 +1,321 @@ +//! The load cases of spec/20 section 20.3.1. +//! +//! Case A loads from a source file that is not in the page cache. The harness drops the caches, checks with `fincore` that no page of the source is resident, and times `cat > /dev/null`. That time is the reference `R`. Then it drops the caches again, checks again, and times the load `L`. The result records `L`, `R` and `L / R`. +//! +//! Case B loads from a source file that is in the page cache. The harness reads the file twice with `cat`, and `fincore` must report at least 99 percent of the pages resident. If fewer pages are resident, the run is case A and the harness does the steps of case A. + +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::json::Json; + +/// The share of resident pages that case B needs. +pub(crate) const CASE_B_RESIDENT: f64 = 0.99; + +/// The case of a load. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Case { + A, + B, +} + +impl Case { + pub(crate) fn parse(s: &str) -> Result { + match s { + "a" | "A" => Ok(Case::A), + "b" | "B" => Ok(Case::B), + _ => Err(format!("usage: the case {s:?} is not a or b")), + } + } + + pub(crate) fn name(self) -> &'static str { + match self { + Case::A => "A", + Case::B => "B", + } + } +} + +/// What `fincore` reports for one file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Fincore { + /// Resident pages. + pub(crate) pages: u64, + /// Resident bytes. + pub(crate) res: u64, + /// The size of the file in bytes. + pub(crate) size: u64, + /// The line that `fincore` printed, for the result. + pub(crate) raw: String, +} + +impl Fincore { + /// The share of the pages of the file that are resident. An empty file counts as fully resident. + pub(crate) fn resident(&self, page_size: u64) -> f64 { + let total = self.size.div_ceil(page_size); + if total == 0 { 1.0 } else { self.pages as f64 / total as f64 } + } + + fn to_json(&self, page_size: u64) -> Json { + Json::obj() + .with("resident_pages", self.pages) + .with("resident_bytes", self.res) + .with("size_bytes", self.size) + .with("resident_share", self.resident(page_size)) + .with("fincore", self.raw.as_str()) + } +} + +/// Parses `fincore --bytes --noheadings --raw --output PAGES,RES,SIZE,FILE`. +fn parse_fincore(text: &str) -> Result { + let line = text.lines().next().unwrap_or("").trim(); + let mut words = line.split_whitespace(); + let mut num = |what: &str| -> Result { + words + .next() + .and_then(|w| w.parse().ok()) + .ok_or(format!("fincore printed {line:?}, with no number for {what}")) + }; + let pages = num("PAGES")?; + let res = num("RES")?; + let size = num("SIZE")?; + Ok(Fincore { pages, res, size, raw: line.to_owned() }) +} + +pub(crate) fn fincore(path: &Path) -> Result { + let out = Command::new("fincore") + .args(["--bytes", "--noheadings", "--raw", "--output", "PAGES,RES,SIZE,FILE"]) + .arg(path) + .output() + .map_err(|e| format!("fincore: {e}. It is in util-linux-extra on Ubuntu"))?; + if !out.status.success() { + return Err(format!( + "fincore {}: {}", + path.display(), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + parse_fincore(&String::from_utf8_lossy(&out.stdout)) +} + +pub(crate) fn page_size() -> Result { + let out = + Command::new("getconf").arg("PAGESIZE").output().map_err(|e| format!("getconf: {e}"))?; + String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .map_err(|_| "getconf PAGESIZE printed no number".to_owned()) +} + +/// `sync`, then `echo 3 > /proc/sys/vm/drop_caches`. It needs root. +pub(crate) fn drop_caches() -> Result<(), String> { + let status = Command::new("sync").status().map_err(|e| format!("sync: {e}"))?; + if !status.success() { + return Err(format!("sync failed: {status}")); + } + fs::write("/proc/sys/vm/drop_caches", "3") + .map_err(|e| format!("drop_caches: {e}. It needs root")) +} + +/// `cat > /dev/null`, timed. +pub(crate) fn timed_cat(path: &Path) -> Result { + let start = Instant::now(); + let status = Command::new("cat") + .arg(path) + .stdout(Stdio::null()) + .status() + .map_err(|e| format!("cat: {e}"))?; + let took = start.elapsed(); + if !status.success() { + return Err(format!("cat {} failed: {status}", path.display())); + } + Ok(took) +} + +/// The steps before the load, and what they found. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Prepared { + /// The case that was asked for. + pub(crate) wanted: Case, + /// The case that the load runs in. + pub(crate) case: Case, + /// Why the case is not the one that was asked for. + pub(crate) note: Option, + /// The reference time `R`. Only case A has it. + pub(crate) read: Option, + /// Each `fincore` check, with the step that ran it. + pub(crate) checks: Vec<(&'static str, Fincore)>, + /// How many drops of the caches each case A check needed. + pub(crate) drops: Vec, + pub(crate) page_size: u64, +} + +impl Prepared { + /// The case A ratio `L / R`. + pub(crate) fn ratio(&self, load: Duration) -> Option { + self.read.map(|r| load.as_secs_f64() / r.as_secs_f64()) + } + + /// Lines for a terminal. + pub(crate) fn text(&self, load: Duration) -> String { + let mut t = + format!("case {} (asked for {})\n", self.case.name(), self.wanted.name()); + if let Some(note) = &self.note { + t.push_str(&format!("note {note}\n")); + } + for (step, f) in &self.checks { + t.push_str(&format!( + "fincore {:.2} percent resident, {step}: {}\n", + f.resident(self.page_size) * 100.0, + f.raw + )); + } + if !self.drops.is_empty() { + let drops: Vec = self.drops.iter().map(u32::to_string).collect(); + t.push_str(&format!( + "drops {} (drops of the caches before each check)\n", + drops.join(", ") + )); + } + if let Some(r) = self.read { + t.push_str(&format!("R (cat) {:.3} s\n", r.as_secs_f64())); + } + t.push_str(&format!("L (load) {:.3} s\n", load.as_secs_f64())); + if let Some(ratio) = self.ratio(load) { + t.push_str(&format!("L / R {ratio:.3}\n")); + } + t + } + + pub(crate) fn to_json(&self, load: Option) -> Json { + let checks: Vec = self + .checks + .iter() + .map(|(step, f)| f.to_json(self.page_size).with("step", *step)) + .collect(); + Json::obj() + .with("case_wanted", self.wanted.name()) + .with("case", self.case.name()) + .with("note", self.note.clone()) + .with("read_s", self.read.map(|r| r.as_secs_f64())) + .with("load_s", load.map(|l| l.as_secs_f64())) + .with("load_over_read", load.and_then(|l| self.ratio(l))) + .with("page_size", self.page_size) + .with("drops", self.drops.clone()) + .with("fincore", Json::Arr(checks)) + } +} + +/// Runs the steps of spec/20 section 20.3.1 before the load. The caller times the load right after this returns. +pub(crate) fn prepare(source: &Path, wanted: Case) -> Result { + let page_size = page_size()?; + let mut p = Prepared { + wanted, + case: wanted, + note: None, + read: None, + checks: Vec::new(), + drops: Vec::new(), + page_size, + }; + if wanted == Case::B { + timed_cat(source)?; + timed_cat(source)?; + let f = fincore(source)?; + let share = f.resident(page_size); + p.checks.push(("case B, after two reads", f)); + if share >= CASE_B_RESIDENT { + return Ok(p); + } + p.case = Case::A; + p.note = Some(format!( + "only {:.2} percent of the pages were resident after two reads, below the 99 percent of case B, so the run is case A", + share * 100.0 + )); + } + for (i, step) in ["case A, before the read", "case A, before the load"].into_iter().enumerate() + { + let (f, drops) = cold(source, page_size, step)?; + p.checks.push((step, f)); + p.drops.push(drops); + if i == 0 { + p.read = Some(timed_cat(source)?); + } + } + Ok(p) +} + +/// The number of times the harness drops the caches before it gives up on a source that stays resident. +const DROP_TRIES: u32 = 5; + +/// Drops the caches until `fincore` reports 0 resident pages, and returns the check and the number of drops. A few pages can stay after one drop while they sit in a per CPU list of the kernel, so a second drop removes them. Pages that stay after five drops are held by another process, or the file is on tmpfs, and the run fails. +fn cold(source: &Path, page_size: u64, step: &str) -> Result<(Fincore, u32), String> { + let mut last = None; + for drops in 1..=DROP_TRIES { + drop_caches()?; + let f = fincore(source)?; + if f.pages == 0 { + return Ok((f, drops)); + } + last = Some(f); + std::thread::sleep(Duration::from_millis(200)); + } + let f = last.expect("the loop ran at least once"); + Err(format!( + "{step}: {} has {} resident pages ({:.2} percent) after {DROP_TRIES} drops of the caches. Another process holds the file open with mapped pages, or the file is on tmpfs", + source.display(), + f.pages, + f.resident(page_size) * 100.0 + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fincore_line() { + let f = parse_fincore("2560 10485760 10485761 /data/hits.tsv\n").unwrap(); + assert_eq!((f.pages, f.res, f.size), (2560, 10485760, 10485761)); + // 10485761 bytes are 2561 pages of 4096 bytes. + assert!((f.resident(4096) - 2560.0 / 2561.0).abs() < 1e-12); + assert_eq!(f.raw, "2560 10485760 10485761 /data/hits.tsv"); + assert!(parse_fincore("").is_err()); + assert!(parse_fincore("12 x 3 f").unwrap_err().contains("RES")); + } + + #[test] + fn an_empty_file_is_resident() { + let f = Fincore { pages: 0, res: 0, size: 0, raw: String::new() }; + assert_eq!(f.resident(4096), 1.0); + } + + #[test] + fn the_ratio_needs_case_a() { + let mut p = Prepared { + wanted: Case::B, + case: Case::B, + note: None, + read: None, + checks: Vec::new(), + drops: Vec::new(), + page_size: 4096, + }; + assert_eq!(p.ratio(Duration::from_secs(5)), None); + p.case = Case::A; + p.read = Some(Duration::from_secs(4)); + assert_eq!(p.ratio(Duration::from_secs(5)), Some(1.25)); + let text = p.to_json(Some(Duration::from_secs(5))).pretty(); + assert!(text.contains("\"case_wanted\": \"B\"")); + assert!(text.contains("\"load_over_read\": 1.25")); + } + + #[test] + fn case_names() { + assert_eq!(Case::parse("a").unwrap(), Case::A); + assert_eq!(Case::parse("B").unwrap(), Case::B); + assert!(Case::parse("c").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 64b9510..aa8b91a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,8 @@ #![forbid(unsafe_code)] -use std::process::ExitCode; - use std::path::Path; +use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::cgroup::Cgroup; @@ -16,6 +15,7 @@ mod args; mod cgroup; mod gates; mod json; +mod load; mod pins; mod toml; @@ -30,6 +30,10 @@ commands: --name starts COMMAND in the new cgroup /sys/fs/cgroup/rupg-bench/N. --attach and --unit measure a cgroup that exists, such as a systemd unit, while COMMAND (the client) runs outside it. --idle measures the idle base first (10 s by default). + load --case a|b --source FILE (--name N [--cpus LIST] | --attach PATH | --unit UNIT) [--du PATH] [--json] -- COMMAND... + run the steps of case A or case B of spec/20 section 20.3.1 on FILE + (drop_caches, fincore, the timed cat), then time COMMAND, the load, + in the cgroup runner. Case A records L, R and L / R. --version print the version"; fn main() -> ExitCode { @@ -70,6 +74,7 @@ fn run(argv: &[String]) -> Result<(), String> { } } "measure" => measure(a)?, + "load" => load_command(a)?, "--version" | "-V" => println!("rupg-bench {}", env!("CARGO_PKG_VERSION")), "--help" | "-h" | "help" => println!("{USAGE}"), other => return Err(format!("usage: unknown command {other:?}\n{USAGE}")), @@ -77,12 +82,91 @@ fn run(argv: &[String]) -> Result<(), String> { Ok(()) } +/// The cgroup of `--name N [--cpus LIST]`, `--attach PATH` or `--unit UNIT`. +fn target(a: &mut args::Args) -> Result { + let cpus = a.value("cpus"); + let cg = match (a.value("name"), a.value("attach"), a.value("unit")) { + (Some(n), None, None) => Cgroup::create(&n, cpus.as_deref())?, + (None, Some(p), None) => Cgroup::attach(Path::new(&p))?, + (None, None, Some(u)) => Cgroup::of_unit(&u)?, + _ => return Err("usage: give one of --name, --attach and --unit".to_owned()), + }; + if cpus.is_some() && !cg.owned() { + return Err("usage: --cpus works only with --name".to_owned()); + } + Ok(cg) +} + +/// A result as JSON and as lines for a terminal. +#[derive(Debug)] +struct Out { + json: Json, + text: String, +} + +impl Out { + fn new(cg: &Cgroup) -> Out { + let json = Json::obj() + .with("cgroup", cg.path.display().to_string()) + .with( + "unix_time", + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()), + ) + .with( + "kernel", + std::fs::read_to_string("/proc/sys/kernel/osrelease").unwrap_or_default().trim(), + ); + Out { json, text: format!("cgroup {}\n", cg.path.display()) } + } + + fn add(&mut self, key: &str, value: impl Into, text: &str) { + let json = std::mem::replace(&mut self.json, Json::Null); + self.json = json.with(key, value); + self.text.push_str(text); + } + + fn print(&self, json: bool) { + if json { + print!("{}", self.json.pretty()); + } else { + print!("{}", self.text); + } + } + + /// Runs the command, adds its numbers and fails if it failed. + fn run(&mut self, cg: &Cgroup, argv: &[String]) -> Result { + let (status, usage) = cgroup::run(cg, argv)?; + self.add("command", argv.to_vec(), &format!("command {}\n", argv.join(" "))); + self.add("exit_code", status.code().map(i64::from), &format!("exit {status}\n")); + self.add("run", usage.to_json(), &usage.text()); + if status.success() { Ok(usage) } else { Err(format!("the command failed: {status}")) } + } + + fn du(&mut self, path: Option) -> Result<(), String> { + if let Some(path) = path { + let bytes = cgroup::du_apparent(Path::new(&path))?; + let text = format!("du {path} {}\n", cgroup::mib(bytes)); + self.add("du", Json::obj().with("path", path).with("apparent_bytes", bytes), &text); + } + Ok(()) + } +} + +/// Runs `body`, prints the result, and removes the cgroup, also when `body` fails. +fn with_cgroup( + cg: Cgroup, + json: bool, + body: impl FnOnce(&Cgroup, &mut Out) -> Result<(), String>, +) -> Result<(), String> { + let mut out = Out::new(&cg); + let result = body(&cg, &mut out); + out.print(json); + let removed = cg.remove(); + result.and(removed) +} + /// The `measure` command. fn measure(mut a: args::Args) -> Result<(), String> { - let name = a.value("name"); - let attach = a.value("attach"); - let unit = a.value("unit"); - let cpus = a.value("cpus"); // --idle alone is the 10 s of spec/20 section 20.4. let idle = if a.flag("idle") { Some(cgroup::IDLE_BASE) @@ -100,82 +184,55 @@ fn measure(mut a: args::Args) -> Result<(), String> { let du = a.value("du"); let json = a.flag("json"); let argv = std::mem::take(&mut a.rest); - a.finish()?; - let cg = match (name, attach, unit) { - (Some(n), None, None) => { - if idle.is_some() { - return Err("usage: --idle needs --attach or --unit, because a new cgroup has nothing in it to measure".to_owned()); - } - if argv.is_empty() { - return Err("usage: --name needs a command after --".to_owned()); - } - Cgroup::create(&n, cpus.as_deref())? - } - (None, Some(p), None) => Cgroup::attach(Path::new(&p))?, - (None, None, Some(u)) => Cgroup::of_unit(&u)?, - _ => return Err("usage: give one of --name, --attach and --unit".to_owned()), - }; - if cpus.is_some() && !cg.owned() { - return Err("usage: --cpus works only with --name".to_owned()); + let new = a.value_is_set("name"); + if new && idle.is_some() { + return Err("usage: --idle needs --attach or --unit, because a new cgroup has nothing in it to measure".to_owned()); } - let mut out = Json::obj() - .with("cgroup", cg.path.display().to_string()) - .with("unix_time", SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())) - .with( - "kernel", - std::fs::read_to_string("/proc/sys/kernel/osrelease").unwrap_or_default().trim(), - ) - .with("cpus", cpus.clone()); - let mut text = format!("cgroup {}\n", cg.path.display()); - if let Some(d) = idle { - let base = cgroup::idle_base(&cg, d)?; - text.push_str(&format!("idle base over {} s:\n{}", d.as_secs_f64(), base.text())); - out = out.with("idle_base", base.to_json()); + if new && argv.is_empty() { + return Err("usage: --name needs a command after --".to_owned()); } - if !argv.is_empty() { - // In a new cgroup the command is the system. With --attach or --unit the command is the client, and it runs outside the cgroup that is measured. - let mut cmd = if cg.owned() { - cg.command(&argv)? - } else { - let mut c = std::process::Command::new(&argv[0]); - c.args(&argv[1..]); - c - }; - let interval = cgroup::Interval::start(&cg)?; - let status = cmd.status().map_err(|e| format!("{}: {e}", argv[0])); - let usage = interval.finish()?; - let status = status?; - text.push_str(&format!( - "command {}\nexit {status}\n{}", - argv.join(" "), - usage.text() - )); - out = out - .with("command", argv.clone()) - .with("exit_code", status.code().map(i64::from)) - .with("run", usage.to_json()); - if cg.owned() { - cg.kill_all()?; + let cg = target(&mut a)?; + a.finish()?; + with_cgroup(cg, json, |cg, out| { + if let Some(d) = idle { + let base = cgroup::idle_base(cg, d)?; + out.add( + "idle_base", + base.to_json(), + &format!("idle base over {} s:\n{}", d.as_secs_f64(), base.text()), + ); } - if !status.success() { - emit(json, &out, &text); - cg.remove()?; - return Err(format!("the command failed: {status}")); + if !argv.is_empty() { + out.run(cg, &argv)?; } - } - if let Some(path) = du { - let bytes = cgroup::du_apparent(Path::new(&path))?; - text.push_str(&format!("du {path} {}\n", cgroup::mib(bytes))); - out = out.with("du", Json::obj().with("path", path).with("apparent_bytes", bytes)); - } - emit(json, &out, &text); - cg.remove() + out.du(du) + }) } -fn emit(json: bool, out: &Json, text: &str) { - if json { - print!("{}", out.pretty()); - } else { - print!("{text}"); +/// The `load` command: the steps of case A or case B of spec/20 section 20.3.1, then the load in the cgroup runner. +fn load_command(mut a: args::Args) -> Result<(), String> { + let case = + load::Case::parse(&a.value("case").ok_or("usage: load needs --case a or --case b")?)?; + let source = a.value("source").ok_or("usage: load needs --source FILE")?; + let du = a.value("du"); + let json = a.flag("json"); + let argv = std::mem::take(&mut a.rest); + if argv.is_empty() { + return Err("usage: load needs the load command after --".to_owned()); } + let cg = target(&mut a)?; + a.finish()?; + with_cgroup(cg, json, |cg, out| { + let source = Path::new(&source); + let prepared = load::prepare(source, case)?; + // The load starts right after the last check. spec/20 section 20.3.4 measures the resources over the load. + let usage = out.run(cg, &argv)?; + out.add( + "source", + source.display().to_string(), + &format!("source {}\n", source.display()), + ); + out.add("load_case", prepared.to_json(Some(usage.wall)), &prepared.text(usage.wall)); + out.du(du) + }) }