Skip to content

Commit bab655e

Browse files
fix(bench): hot loops join vm.json, a broken snapshot fails, and memory keeps every peak past a 4 KB floor
1 parent 144c936 commit bab655e

8 files changed

Lines changed: 5673 additions & 5255 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,14 @@ deno lint js/
6161

6262
### Bench
6363

64-
`cargo run -p bench --profile cli` runs every case of `vm.json` on the `cargo wasm-cli` build, and stops when that build is older than `src/`. It counts the WebAssembly instructions each case executes and prices each at 0.82 ns, the 822756 gas that `wasm_regular_op_cost` sets in `core/parameters/res/runtime_configs/parameters.yaml` of nearcore, at the 1 ms per Tgas its gas estimator budgets, so `bench/.snapshot` keeps reference seconds that come out the same on every machine. Each case keeps them beside its memory peak in MB, by the model the memory limit counts, as `[seconds, MB]`.
64+
`cargo run -p bench --profile cli` runs every case of `vm.json` on the `cargo wasm-cli` build, and stops when that build is older than `src/`. It counts the WebAssembly instructions each case executes and prices each at 0.82 ns, the 822756 gas that `wasm_regular_op_cost` sets in `core/parameters/res/runtime_configs/parameters.yaml` of nearcore, at the 1 ms per Tgas its gas estimator budgets, so `bench/.snapshot` keeps reference seconds that come out the same on every machine. Each case keeps them beside its memory peak in MB, by the model the memory limit counts, as `[seconds, MB]`. The peak includes garbage not yet collected, so a change to when the collector runs moves it too.
6565

6666
The Bench job fails a pull request when
6767

6868
- the snapshot was taken with another Rust,
6969
- a case is missing from it, or an entry has no case,
7070
- the geometric mean of time or memory moves past its threshold, either way,
71-
- a single case moves past its own threshold in either.
71+
- a single case moves past its own threshold in either, and in memory also by more than `memory_floor` MB.
7272

7373
Every run prints how the cases already in the snapshot moved, even when another rule fails. `--update` reports the change and takes the snapshot again, and a faster engine takes it too, so the next change is measured from where the code stands.
7474

‎bench/.snapshot‎

Lines changed: 5413 additions & 5222 deletions
Large diffs are not rendered by default.

‎bench/src/main.rs‎

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@ struct Snapshot {
2222
rustc: String,
2323
threshold: f64,
2424
case_threshold: f64,
25+
// MB a case may move in memory before its own threshold applies, so one small object is not a finding.
26+
memory_floor: f64,
2527
cases: BTreeMap<String, (f64, f64)>,
2628
}
2729

2830
// How a report words one measure, so time and memory are held to the same rules.
2931
struct Measure {
3032
pick: fn((f64, f64)) -> f64,
33+
floor: fn(&Snapshot) -> f64,
3134
unit: &'static str,
3235
places: usize,
3336
grew: &'static str,
@@ -38,8 +41,8 @@ struct Measure {
3841
total: (&'static str, &'static str),
3942
}
4043

41-
const TIME: Measure = Measure { pick: |c| c.0, unit: "s", places: 9, grew: "got slower", shrank: "got faster", most_grown: "Most slowed", most_shrunk: "Biggest gains", own: "", total: ("runs in", "of reference time") };
42-
const MEMORY: Measure = Measure { pick: |c| c.1, unit: "MB", places: 6, grew: "holds more memory", shrank: "holds less memory", most_grown: "Most grown", most_shrunk: "Biggest drops", own: " in memory", total: ("peaks at", "summed over its cases") };
44+
const TIME: Measure = Measure { pick: |c| c.0, floor: |_| 0.0, unit: "s", places: 9, grew: "got slower", shrank: "got faster", most_grown: "Most slowed", most_shrunk: "Biggest gains", own: "", total: ("runs in", "of reference time") };
45+
const MEMORY: Measure = Measure { pick: |c| c.1, floor: |s| s.memory_floor, unit: "MB", places: 6, grew: "holds more memory", shrank: "holds less memory", most_grown: "Most grown", most_shrunk: "Biggest drops", own: " in memory", total: ("peaks at", "summed over its cases") };
4346

4447
fn main() {
4548
let update = std::env::args().any(|a| a == "--update");
@@ -60,17 +63,28 @@ fn main() {
6063
let ran: Vec<(String, (u64, u64))> = cases.iter().map(|case| (key(case), run(&engine, &module, case))).collect();
6164
let measured = ran.iter().map(|(k, (ops, peak))| (k.clone(), ((*ops as f64 * SECONDS_PER_INSTRUCTION * 1e9).round() / 1e9, (*peak as f64 / MB * 1e6).round() / 1e6))).collect();
6265
let rustc = Command::new("rustc").arg("--version").current_dir(ROOT).output().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
63-
let now = Snapshot { rustc, threshold: 0.005, case_threshold: 0.05, cases: measured };
66+
let now = Snapshot { rustc, threshold: 0.005, case_threshold: 0.05, memory_floor: 0.004, cases: measured };
6467
let sources: BTreeMap<String, &str> = cases.iter().map(|c| (key(c), c["src"].as_str().unwrap_or(""))).collect();
6568

66-
let last = std::fs::read_to_string(SNAPSHOT).ok().and_then(|s| serde_json::from_str::<Snapshot>(&s).ok());
69+
// Only a missing snapshot is taken without comparing, one that does not parse fails unless --update replaces it.
70+
let last = match std::fs::read_to_string(SNAPSHOT) {
71+
Err(_) => None,
72+
Ok(text) => match serde_json::from_str::<Snapshot>(&text) {
73+
Ok(snapshot) => Some(snapshot),
74+
Err(_) if update => None,
75+
Err(e) => {
76+
annotate("error", &format!("bench/.snapshot does not parse, {e}, take it again with --update."));
77+
std::process::exit(1);
78+
}
79+
},
80+
};
6781
let failures = last.as_ref().map(|l| check(l, &now, &sources)).unwrap_or_default();
6882
for failure in &failures {
6983
annotate("error", failure);
7084
}
7185
if update || last.is_none() {
72-
let thresholds = last.map_or((now.threshold, now.case_threshold), |l| (l.threshold, l.case_threshold));
73-
write(&Snapshot { threshold: thresholds.0, case_threshold: thresholds.1, ..now });
86+
let (threshold, case_threshold, memory_floor) = last.map_or((now.threshold, now.case_threshold, now.memory_floor), |l| (l.threshold, l.case_threshold, l.memory_floor));
87+
write(&Snapshot { threshold, case_threshold, memory_floor, ..now });
7488
return println!(" snapshot written");
7589
}
7690
if !failures.is_empty() {
@@ -98,9 +112,10 @@ fn check(last: &Snapshot, now: &Snapshot, sources: &BTreeMap<String, &str>) -> V
98112
/* One measure against the snapshot, its geometric mean held to the band and each case to its own threshold. */
99113
fn compare(m: &Measure, last: &Snapshot, now: &Snapshot, sources: &BTreeMap<String, &str>) -> Vec<String> {
100114
let mut failures = Vec::new();
101-
let unit = m.unit;
115+
let (unit, floor) = (m.unit, (m.floor)(last));
116+
let all: Vec<(&String, f64, f64)> = now.cases.iter().filter_map(|(k, &n)| last.cases.get(k).map(|&l| (k, (m.pick)(l), (m.pick)(n)))).collect();
102117
// Every case weighs the same in a geometric mean, so no single long case decides it.
103-
let paired: Vec<(&String, f64, f64)> = now.cases.iter().filter_map(|(k, &n)| last.cases.get(k).map(|&l| (k, (m.pick)(l), (m.pick)(n)))).filter(|&(_, l, n)| l > 0.0 && n > 0.0).collect();
118+
let paired: Vec<(&String, f64, f64)> = all.iter().copied().filter(|&(_, l, n)| l > 0.0 && n > 0.0).collect();
104119
if paired.is_empty() {
105120
return failures;
106121
}
@@ -116,11 +131,14 @@ fn compare(m: &Measure, last: &Snapshot, now: &Snapshot, sources: &BTreeMap<Stri
116131
} else if change < -last.threshold {
117132
failures.push(format!("vm.json {}, {total:.3} {unit} against {before:.3} {unit}, {:+.2}%. The snapshot no longer matches the code, run --update and commit it with the change. {}{}", m.shrank, change * 100.0, m.most_shrunk, list(&moved(false))));
118133
}
119-
let mut apart: Vec<&(&String, f64, f64)> = paired.iter().filter(|(_, l, n)| (n / l - 1.0).abs() > last.case_threshold).collect();
120-
apart.sort_by(|a, b| (b.2 / b.1).ln().abs().total_cmp(&(a.2 / a.1).ln().abs()));
134+
// A case at 0 has no ratio, so leaving 0 or reaching it moves it on its own, ahead of the rest.
135+
let by = |&(_, l, n): &(&String, f64, f64)| if l > 0.0 && n > 0.0 { (n / l).ln().abs() } else { f64::INFINITY };
136+
let mut apart: Vec<(&String, f64, f64)> = all.iter().copied().filter(|&(_, l, n)| (n - l).abs() > floor && (l == 0.0 || n == 0.0 || (n / l - 1.0).abs() > last.case_threshold)).collect();
137+
apart.sort_by(|a, b| by(b).total_cmp(&by(a)));
121138
if !apart.is_empty() {
122-
let lines: Vec<String> = apart.iter().map(|&&(k, l, n)| moved_line(m, sources, k, l, n)).collect();
123-
failures.push(format!("{} cases moved past ±{:.0}% on their own{}.{}", apart.len(), last.case_threshold * 100.0, m.own, list(&lines)));
139+
let lines: Vec<String> = apart.iter().map(|&(k, l, n)| moved_line(m, sources, k, l, n)).collect();
140+
let past = if floor > 0.0 { format!("±{:.0}% and {floor} {unit}", last.case_threshold * 100.0) } else { format!("±{:.0}%", last.case_threshold * 100.0) };
141+
failures.push(format!("{} cases moved past {past} on their own{}.{}", apart.len(), m.own, list(&lines)));
124142
}
125143

126144
// Printed even when a finding fails, so adding cases never hides how the existing ones moved.
@@ -130,7 +148,8 @@ fn compare(m: &Measure, last: &Snapshot, now: &Snapshot, sources: &BTreeMap<Stri
130148

131149
fn moved_line(m: &Measure, sources: &BTreeMap<String, &str>, key: &str, last: f64, now: f64) -> String {
132150
let (unit, places) = (m.unit, m.places);
133-
format!("{} {last:.places$} {unit} to {now:.places$} {unit} ({:+.1}%)", source(sources, key), (now / last - 1.0) * 100.0)
151+
let change = if last > 0.0 { format!("{:+.1}%", (now / last - 1.0) * 100.0) } else { String::from("from 0") };
152+
format!("{} {last:.places$} {unit} to {now:.places$} {unit} ({change})", source(sources, key))
134153
}
135154

136155
fn source(sources: &BTreeMap<String, &str>, key: &str) -> String {
@@ -209,8 +228,8 @@ fn write(snapshot: &Snapshot) {
209228
let (t, m) = (TIME.places, MEMORY.places);
210229
let cases: Vec<String> = snapshot.cases.iter().map(|(k, (s, mb))| format!(" \"{k}\": [{s:.t$}, {mb:.m$}]")).collect();
211230
let json = format!(
212-
"{{\n \"rustc\": {:?},\n \"threshold\": {},\n \"case_threshold\": {},\n \"cases\": {{\n{}\n }}\n}}\n",
213-
snapshot.rustc, snapshot.threshold, snapshot.case_threshold, cases.join(",\n")
231+
"{{\n \"rustc\": {:?},\n \"threshold\": {},\n \"case_threshold\": {},\n \"memory_floor\": {},\n \"cases\": {{\n{}\n }}\n}}\n",
232+
snapshot.rustc, snapshot.threshold, snapshot.case_threshold, snapshot.memory_floor, cases.join(",\n")
214233
);
215234
std::fs::write(SNAPSHOT, json).expect("writing bench/.snapshot");
216235
}

‎docs/03-reference/06-abi.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ Distinct from the sealed plugin imports above, these are exports on `compiler.wa
446446
|---|---|---|
447447
| `set_limits` | `(memory: u64, ops: u64)` | Caps for the next `run_start` or `repl_eval`, memory in bytes. A zero field keeps the sandbox value. `restore_state` keeps the limits embedded in the blob. |
448448
| `set_entry` | `(ptr: *const u8, len: u32)` | Name the script the next run starts from. Tracebacks name its frame after it, and its directory roots the walk and the relative imports of the program. An empty entry, or a directory ending in `/`, runs from that directory and renders its frame as `<input>`. |
449-
| `memory_peak` | `() -> u64` | The most the last run held at once, in bytes, by the memory model the limit counts. |
449+
| `memory_peak` | `() -> u64` | The most the last run held at once, in bytes, by the memory model the limit counts, garbage not yet collected included. A run that fails to compile reads 0. |
450450
| `save_state` | `() -> i64` | Serialise the parked run into the out buffer. Returns the blob length, or `-1` when nothing is parked. |
451451
| `restore_state` | `(ptr: *const u8, len: u32) -> u32` | Boot a VM from the blob at `ptr` and overlay its state. Returns the same packed status word as `run_start`. |
452452
| `state_globals` | `() -> u32` | Write the parked run's module-level bindings as JSON into the out buffer. Returns its byte length. |

‎src/value/mod.rs‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -623,8 +623,8 @@ pub struct HeapPool {
623623
alloc_count: Cell<usize>,
624624
// What the slots hold by the memory model, garbage since the last sweep included.
625625
bytes: Cell<usize>,
626-
// The highest count before a sweep, where it stands tallest since only a sweep lowers it.
627-
peak: usize,
626+
// The highest count, kept at each place it goes down, so no high point is missed.
627+
peak: Cell<usize>,
628628
limit: usize,
629629
// The first collection whose running count missed the recount, as the two totals.
630630
#[cfg(feature = "memcheck")]
@@ -668,7 +668,7 @@ impl HeapPool {
668668
gc_threshold: 512,
669669
alloc_count: Cell::new(0),
670670
bytes: Cell::new(0),
671-
peak: 0,
671+
peak: Cell::new(0),
672672
limit,
673673
#[cfg(feature = "memcheck")]
674674
drift: None,
@@ -781,12 +781,16 @@ impl HeapPool {
781781
let out = f(c);
782782
let after = c.bytes();
783783
if after != before {
784+
if after < before { self.keep_peak(); }
784785
self.bytes.set(self.bytes.get().saturating_sub(before));
785786
self.charge(after);
786787
}
787788
out
788789
}
789790

791+
// Remembers the count before it goes down.
792+
fn keep_peak(&self) { self.peak.set(self.peak.get().max(self.bytes.get())); }
793+
790794
/* Charges what an object took, past the limit the next safe point collects. */
791795
#[inline]
792796
pub fn charge(&self, bytes: usize) {
@@ -800,8 +804,8 @@ impl HeapPool {
800804
/* What the slots hold by the memory model, garbage since the last sweep included. */
801805
pub fn bytes(&self) -> usize { self.bytes.get() }
802806

803-
/* The most the slots held at once, garbage included, the count now or before any sweep. */
804-
pub fn peak(&self) -> usize { self.peak.max(self.bytes.get()) }
807+
/* The most the slots held at once, garbage included, the count now or the highest it fell from. */
808+
pub fn peak(&self) -> usize { self.peak.get().max(self.bytes.get()) }
805809

806810
/* The same total counted again from every occupied slot, which the running count must always equal. */
807811
fn recount(&self) -> usize {
@@ -838,7 +842,7 @@ impl HeapPool {
838842
}
839843

840844
pub fn sweep(&mut self) {
841-
self.peak = self.peak.max(self.bytes.get());
845+
self.keep_peak();
842846
// The survivors are counted again, so a sweep leaves the running count exact.
843847
let mut kept = 0;
844848
for idx in 0..self.slots.len() {
@@ -888,6 +892,7 @@ impl HeapPool {
888892
self.live += 1;
889893
self.intern_insert(idx as u32);
890894
}
895+
self.keep_peak();
891896
self.bytes.set(self.recount());
892897
self.gc_threshold = (self.live * 2).max(512);
893898
self.alloc_limit = 4096;
@@ -898,6 +903,7 @@ impl HeapPool {
898903
/* Swap a live slot's object during restore. */
899904
pub(crate) fn replace_obj(&mut self, idx: u32, obj: HeapObj) {
900905
let before = self.slots[idx as usize].obj.as_ref().map_or(0, footprint);
906+
self.keep_peak();
901907
self.bytes.set((self.bytes.get() + footprint(&obj)).saturating_sub(before));
902908
self.slots[idx as usize] = HeapSlot::new(Some(obj));
903909
}

‎src/wasm/mod.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,12 @@ impl Slot {
116116
}
117117
}
118118

119-
/* Drops the run and REPL interpreters, then the chunks they borrowed. */
119+
/* Drops the run and REPL interpreters and the last memory peak, then the chunks they borrowed. */
120120
pub fn clear_run(&mut self) {
121121
self.paused_run = None;
122122
self.repl_vm = None;
123123
self.repl_mode = false;
124+
self.memory_peak = 0;
124125
self.chunks.clear();
125126
}
126127
}

0 commit comments

Comments
 (0)