cove_runtime/vm/profile.rs
1//! Where a run spends its instructions.
2//!
3//! A profiler is a **debugger that never stops**. That is the whole design,
4//! and everything good about it follows from reusing a seam this crate
5//! already has rather than opening a second one.
6//!
7//! [`Debugger::at`] is called before every instruction while one is
8//! installed, and a [`Stop`] answers which function and which program counter
9//! that is. A profiler records the pair and answers [`Resume::Go`]. Nothing
10//! is added to `Machine`, nothing is added to the dispatch loop, and a run
11//! that asks for no profile is **byte for byte the run it was**.
12//!
13//! # Why not sample at the safepoint
14//!
15//! It was written that way first: a `Profile` on the machine, read at the
16//! safepoint the loop already stops at, one sample per
17//! [`SAFEPOINT_STRIDE`](crate::vm::exec::SAFEPOINT_STRIDE) instructions. It
18//! measured **2.9% on `arith` with no profile installed** — an `Option` field
19//! nothing read, paid for by every run through `Machine`'s size, and boxing
20//! it to one word did not move the number.
21//!
22//! That is a cost `docs/PHILOSOPHY.md` would let a profiler buy: it is not a
23//! multiple and not a change of class, and *"a small measured slowdown can buy
24//! those qualities"*. This is the better shape anyway, on two counts that have
25//! nothing to do with the 2.9%. It costs **nothing** when off rather than a
26//! little, and it **counts** rather than samples — every instruction, not one
27//! in a thousand — so a function that runs once is in the report and a hot
28//! instruction's share is exact.
29//!
30//! # What it costs while it is on
31//!
32//! What a debugger costs: the machine asks before every instruction rather
33//! than every thousandth, so a profiled run is several times slower than the
34//! same run unprofiled.
35//!
36//! # What the numbers mean, and what they do not
37//!
38//! **Instructions executed** is the count, and it is the number to trust
39//! absolutely: every instruction is counted, not one in a thousand, so a
40//! function that ran once is in the report and a share is exact.
41//!
42//! It is also, alone, **not enough**, and that is why the three figures
43//! beside it are here. A `call-builtin` that allocates a string, a `call`
44//! that pushes a frame and an `add.int` are one instruction each. Replacing
45//! a byte loop in `examples/covefmt` with one `String.contains` cut the run
46//! from 751.1 M instructions to 722.1 M and made it **slower**, and nothing
47//! a count-only profile said would have shown that.
48//!
49//! So a row also carries:
50//!
51//! - **nanoseconds**, measured as the interval between the stop before an
52//! instruction and the stop after it;
53//! - **words** and **allocations**, measured as what the heap handed out
54//! across that same interval.
55//!
56//! The heap figures are exact: a difference of two counters is what happened
57//! in between, whatever it took to happen.
58//!
59//! The time is **not** exact, and reading it as though it were is the mistake
60//! this paragraph exists to stop. The interval holds the instruction, the
61//! dispatch that reached it, and two `Instant::now()` calls — around twenty
62//! nanoseconds of floor that every instruction pays equally, where the
63//! instruction itself may be one nanosecond or five hundred. So:
64//!
65//! - **a ratio of two rows is worth reading**, because the floor is in both;
66//! - **nanoseconds per instruction is the number that finds an expensive
67//! opcode**, because the floor is a constant added to it and a builtin call
68//! stands far above the constant;
69//! - **an absolute figure is worth nothing**, and the total will not agree
70//! with the run's own wall clock — the run being measured is several times
71//! slower than the run anybody cares about.
72//!
73//! A native profiler — `samply`, `sample(1)`, `perf` — has none of that floor
74//! and none of this attribution: it says which *machine* code the time went
75//! to, and this says which *Cove* code. Neither replaces the other.
76//!
77//! # One task
78//!
79//! A debugger is installed on a `Vm`, and a spawned task gets a machine of
80//! its own. So a run that spawns profiles the task the profiler was installed
81//! on. [`crate::Vm::instructions`] is counted the same way and for the same
82//! reason.
83
84use std::collections::HashMap;
85use std::sync::Mutex;
86use std::time::Instant;
87
88use cove_ir::program::FunctionId;
89
90use crate::vm::debug::{Debugger, Resume, Stop};
91
92/// What one instruction of a run cost, summed over every time it ran.
93#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
94pub struct Cost {
95 /// How often it ran.
96 pub ran: u64,
97 /// Nanoseconds spent running it, the profiler's own overhead included.
98 ///
99 /// See the module documentation: the overhead is a constant per
100 /// instruction, so this number is worth reading *against the count* and
101 /// not on its own.
102 pub nanos: u64,
103 /// Words its heap handed out while it ran.
104 pub words: u64,
105 /// Objects its heap handed out while it ran.
106 pub allocations: u64,
107}
108
109impl Cost {
110 /// Everything in `other` added to this.
111 pub fn add(&mut self, other: &Cost) {
112 self.ran += other.ran;
113 self.nanos += other.nanos;
114 self.words += other.words;
115 self.allocations += other.allocations;
116 }
117}
118
119/// What the last stop saw, so that the next one can say what happened in
120/// between.
121#[derive(Debug, Clone, Copy)]
122struct Previous {
123 at: (FunctionId, u32),
124 when: Instant,
125 words: u64,
126 allocations: u64,
127}
128
129/// Counts what a run executes, by the instruction that executed.
130#[derive(Debug, Default)]
131pub struct Profiler {
132 /// What each instruction cost, keyed by the function and the program
133 /// counter — the pair [`cove_ir::print::one`] renders and the debugger
134 /// disassembles.
135 ///
136 /// A `Mutex` because [`Debugger`] is `Sync` and takes `&self`: a run that
137 /// spawns has one machine per task, and two of them may hold the same
138 /// profiler. It is taken once per instruction, which is what makes a
139 /// profiled run slow and is the same order of cost as the stop that
140 /// reached it.
141 at: Mutex<HashMap<(FunctionId, u32), Cost>>,
142 /// The stop before this one, whose instruction is the one that ran.
143 ///
144 /// A stop happens *before* an instruction, so nothing at the stop knows
145 /// what that instruction is about to cost. The stop after it does: the
146 /// clock and the heap have both moved by exactly what it did. So every
147 /// measurement here is recorded one stop late, and the last instruction
148 /// of a run — the `return` out of the entry — is the one instruction no
149 /// later stop closes.
150 last: Mutex<Option<Previous>>,
151}
152
153impl Profiler {
154 /// A profiler with nothing recorded yet.
155 pub fn new() -> Profiler {
156 Profiler::default()
157 }
158
159 /// How many instructions were counted.
160 pub fn counted(&self) -> u64 {
161 self.at
162 .lock()
163 .expect("a lock")
164 .values()
165 .map(|c| c.ran)
166 .sum()
167 }
168
169 /// What the whole run cost, by the four figures a row carries.
170 pub fn total(&self) -> Cost {
171 let mut all = Cost::default();
172 for cost in self.at.lock().expect("a lock").values() {
173 all.add(cost);
174 }
175 all
176 }
177
178 /// Every instruction that ran, and how often, hottest first.
179 ///
180 /// Ties are broken by the instruction's own identity, so two runs of one
181 /// program report the same order: a profile that reordered its own ties
182 /// would make an unchanged program look changed.
183 pub fn hottest(&self) -> Vec<((FunctionId, u32), Cost)> {
184 let held = self.at.lock().expect("a lock");
185 let mut rows: Vec<((FunctionId, u32), Cost)> =
186 held.iter().map(|(at, n)| (*at, *n)).collect();
187 rows.sort_by(|a, b| b.1.ran.cmp(&a.1.ran).then(a.0.cmp(&b.0)));
188 rows
189 }
190
191 /// Every function that ran, and what the instructions of it cost, hottest
192 /// first.
193 pub fn by_function(&self) -> Vec<(FunctionId, Cost)> {
194 let held = self.at.lock().expect("a lock");
195 let mut per: HashMap<FunctionId, Cost> = HashMap::new();
196 for ((function, _), cost) in held.iter() {
197 per.entry(*function).or_default().add(cost);
198 }
199 let mut rows: Vec<(FunctionId, Cost)> = per.into_iter().collect();
200 rows.sort_by(|a, b| b.1.ran.cmp(&a.1.ran).then(a.0.cmp(&b.0)));
201 rows
202 }
203
204 /// Every instruction that ran and what it cost, in no particular order.
205 ///
206 /// For a reader that wants to group by something only the program knows —
207 /// the opcode an instruction is, the callee a `call` names — which this
208 /// crate deliberately does not: a profiler that read the program would
209 /// have to be given one, and the two things that want these groupings
210 /// already hold it.
211 pub fn rows(&self) -> Vec<((FunctionId, u32), Cost)> {
212 self.at
213 .lock()
214 .expect("a lock")
215 .iter()
216 .map(|(at, cost)| (*at, *cost))
217 .collect()
218 }
219}
220
221impl Debugger for Profiler {
222 /// Closes the instruction that just ran and opens the one about to.
223 ///
224 /// The count belongs to the instruction this stop is *before*; the time
225 /// and the heap belong to the one the previous stop was before, because
226 /// those are what moved in between.
227 ///
228 /// The clock is read twice, first thing and last thing, and the bookkeeping
229 /// sits between the two reads. So the interval a number is measured over
230 /// holds the instruction, the dispatch that reached it and two clock
231 /// reads — and **not** this hook's mutex and map, which is the expensive
232 /// part and which would otherwise swamp what it is trying to measure. An
233 /// `add.int` is a nanosecond or two and the map is a hundred.
234 fn at(&self, stop: &Stop<'_>) -> Resume {
235 let now = Instant::now();
236 let here = (stop.function_id(), stop.pc());
237 let words = stop.allocated_words();
238 let allocations = stop.allocations();
239 let mut held = self.at.lock().expect("a lock");
240 held.entry(here).or_default().ran += 1;
241 let mut last = self.last.lock().expect("a lock");
242 if let Some(before) = *last {
243 let cost = held.entry(before.at).or_default();
244 cost.nanos += now.saturating_duration_since(before.when).as_nanos() as u64;
245 cost.words += words.saturating_sub(before.words);
246 cost.allocations += allocations.saturating_sub(before.allocations);
247 }
248 *last = Some(Previous {
249 at: here,
250 words,
251 allocations,
252 when: Instant::now(),
253 });
254 Resume::Go
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::vm::debug::tests::World;
262
263 /// A loop, so the instruction the loop is made of is counted many times
264 /// and the one before it once.
265 const COUNTED: &str = "export fn main() -> Int {\n \
266 var total = 0\n \
267 var i = 0\n \
268 while i < 100 {\n \
269 total = total + i\n \
270 i = i + 1\n \
271 }\n \
272 total\n\
273 }\n";
274
275 /// **The count is the run's own count.**
276 ///
277 /// A profile that disagreed with `Vm::instructions` would be measuring
278 /// something other than the run, and which of the two to believe would be
279 /// an open question at every reading. They are the same number because
280 /// the profiler is called once per instruction by the same loop that
281 /// increments the counter.
282 #[test]
283 fn every_instruction_the_run_executed_is_counted_once() {
284 let world = World::new(COUNTED);
285 let profiler = Profiler::new();
286 let mut vm = world.watched(&profiler);
287 vm.run_entry("m", "main", Vec::new()).expect("it answers");
288 assert_eq!(profiler.counted(), vm.instructions());
289 assert!(profiler.counted() > 100, "{}", profiler.counted());
290 }
291
292 /// The hottest instruction is one the loop runs, and the report is sorted
293 /// by how often rather than by where.
294 #[test]
295 fn the_hottest_instruction_is_one_the_loop_runs() {
296 let world = World::new(COUNTED);
297 let profiler = Profiler::new();
298 let mut vm = world.watched(&profiler);
299 vm.run_entry("m", "main", Vec::new()).expect("it answers");
300
301 let hottest = profiler.hottest();
302 assert!(hottest.len() > 1, "more than one instruction ran");
303 for pair in hottest.windows(2) {
304 assert!(pair[0].1.ran >= pair[1].1.ran, "sorted by how often");
305 }
306 assert!(
307 hottest[0].1.ran >= 100,
308 "the top instruction is one of the hundred turns, not the prologue"
309 );
310 }
311
312 /// **Every allocation the run made is attributed to an instruction.**
313 ///
314 /// The heap figures are a difference of two counters read at two stops,
315 /// so what they cannot do is lose one: whatever the instruction between
316 /// them did, the counters moved by it. That is the property worth pinning,
317 /// because the alternative — a seam inside `Memory::alloc` that knew which
318 /// instruction it was serving — is the one this design avoids having.
319 #[test]
320 fn what_the_heap_handed_out_is_attributed_to_the_instructions_that_ran() {
321 let world = World::new(ALLOCATES);
322 let profiler = Profiler::new();
323 let mut vm = world.watched(&profiler);
324 vm.run_entry("m", "main", Vec::new()).expect("it answers");
325
326 let total = profiler.total();
327 assert!(
328 total.allocations > 0,
329 "a program that builds strings allocates: {total:?}"
330 );
331 assert!(
332 total.words >= total.allocations,
333 "an object is at least its header: {total:?}"
334 );
335 // The rows are the whole of it, so their sum is the total.
336 let mut summed = Cost::default();
337 for (_, cost) in profiler.rows() {
338 summed.add(&cost);
339 }
340 assert_eq!(summed.allocations, total.allocations);
341 assert_eq!(summed.words, total.words);
342 }
343
344 /// **A `call-builtin` that allocates is dearer than an `add.int`, and the
345 /// profile says so.**
346 ///
347 /// This is the whole reason the timing is here. A count cannot separate
348 /// the two — they are one instruction each — and separating them is what
349 /// a reader is trying to do when they ask where a run went.
350 #[test]
351 fn an_instruction_that_allocates_costs_more_than_one_that_adds() {
352 let world = World::new(ALLOCATES);
353 let profiler = Profiler::new();
354 let mut vm = world.watched(&profiler);
355 vm.run_entry("m", "main", Vec::new()).expect("it answers");
356
357 let mut dearest = 0.0_f64;
358 let mut cheapest = f64::MAX;
359 for ((_, _), cost) in profiler.rows() {
360 if cost.ran < 10 {
361 continue;
362 }
363 let each = cost.nanos as f64 / cost.ran as f64;
364 if cost.allocations > 0 {
365 dearest = dearest.max(each);
366 } else {
367 cheapest = cheapest.min(each);
368 }
369 }
370 assert!(
371 dearest > cheapest,
372 "an allocating instruction averaged {dearest} ns and the cheapest \
373 non-allocating one {cheapest}"
374 );
375 }
376
377 /// A loop that builds a string a turn, so that some instructions allocate
378 /// and the ones around them do not.
379 const ALLOCATES: &str = "export fn main() -> Int {\n \
380 var total = 0\n \
381 var i = 0\n \
382 while i < 200 {\n \
383 let text = \"n={i}\"\n \
384 total = total + text.length()\n \
385 i = i + 1\n \
386 }\n \
387 total\n\
388 }\n";
389
390 /// One function here, and all of the instructions are its.
391 #[test]
392 fn a_function_holds_the_instructions_of_its_own_program_counters() {
393 let world = World::new(COUNTED);
394 let profiler = Profiler::new();
395 let mut vm = world.watched(&profiler);
396 vm.run_entry("m", "main", Vec::new()).expect("it answers");
397
398 let per = profiler.by_function();
399 assert_eq!(per.len(), 1, "one function ran");
400 assert_eq!(per[0].1.ran, profiler.counted());
401 }
402
403 /// A profiler that watched nothing counts nothing, which is what a report
404 /// over a run that never started has to say.
405 #[test]
406 fn a_profiler_that_watched_nothing_counts_nothing() {
407 let profiler = Profiler::new();
408 assert_eq!(profiler.counted(), 0);
409 assert!(profiler.hottest().is_empty());
410 assert!(profiler.by_function().is_empty());
411 }
412}