Skip to main content

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}