Viscosity
Review
Part X · Drip in Rust · Day 42

165. Profiling the Rust VM

drip-rs runs fib in about a second and a half. A timer can tell you that; it can’t tell you where the second and a half goes. A profiler can, down to the line, and it brings a surprise about what it is really counting.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Profile a release build of drip-rs with callgrind and read callgrind_annotate’s output.
  • Tell self cost from inclusive cost, and check a profile against what you can count by hand.
  • Explain why inlining hides functions, and how line tables bring them back.
  • Read a flame graph, and say what its width, height and order mean.

Where does the second and a half go?

In the committed benchmark run, drip-rs ran fib.drip in 1460 ms. That number came from a stopwatch around the whole process. It says nothing about what the VM was doing inside. Chapter 24 warned against guessing, and guesses about hot code are wrong surprisingly often. So before the next chapter changes anything, we measure where the work goes.

Intuition

A stopwatch tells you that your day lasted sixteen hours. A diary, filled in every few minutes, tells you that four of them went on email. A profiler is the diary.

Two ways to watch a program

A sampling profiler such as Linux perf interrupts the program many times a second and writes down the call stack. Functions that show up in more samples took more time. It sees real time, cache misses and all, but its numbers are estimates.

The book’s VM has no perf, but it has Valgrind. Its tool callgrind runs the program on a simulated CPU and counts every machine instruction it executes, with the function and source line each belongs to, and who called whom, how often[1]. Nothing is estimated. The event it counts is called Ir, instructions read.

Definition

Profiling is measuring where a program spends its time (or instructions, or memory), per function or per line. A sampling profiler records stacks at intervals and measures time statistically. An instrumenting or simulating profiler, like callgrind, counts events exactly, at the cost of running much slower.

A profile of fib

I built drip-rs with cargo build --release (rustc 1.97, into a scratch directory) and ran it under valgrind 3.22 on the book’s shared VM:

valgrind --tool=callgrind \
  --callgrind-out-file=cg.out \
  target/release/drip-rs fib.drip
callgrind_annotate cg.out

It took 89 seconds, and fib’s own clock printed 88.6. That’s about 60 times slower than a normal run. The top of the report, with the names trimmed from forms like ???:<drip::vm::Vm>::run [path]:

20,186,274,402 (100.0%)  PROGRAM TOTALS
15,676,869,738 (77.66%)  Vm::run
 2,418,717,018 (11.98%)  Vm::call
 1,194,428,240 ( 5.92%)  Table::get
   716,657,004 ( 3.55%)  Vm::call_value

Twenty billion instructions. The slowdown doesn’t change the count: these are the instructions the program runs at full speed too. And the count barely moves between runs. I profiled zoo.drip twice and got 7,528,846,449 and 7,528,846,360. On a machine where wall times wander by a quarter, that steadiness is worth a lot.

Self and inclusive

That list is sorted by self cost: instructions in the function’s own code. --inclusive=yes adds in everything each function called[1]:

20,185,841,592 (100.0%)  Vm::run
 3,135,374,121 (15.53%)  Vm::call_value
 2,418,717,118 (11.98%)  Vm::call
 1,194,428,240 ( 5.92%)  Table::get
   179,164,224 ( 0.89%)  Vm::close_upvalues

call_value does 3.55% itself, and 15.53% counting call. Now check the profile against something you can count. --tree=calling says run called call_value 29,860,705 times. fib(35) makes 29,860,703 calls (twice fib(36), minus one), and the script calls clock() twice. It matches exactly.

More arithmetic. A call to fib runs 7 bytecodes if n < 2 and 17 if not. That’s 358,328,431 bytecodes, about 56 machine instructions each. Table::get costs 40 instructions per call: every recursive call looks up the global fib by name. And close_upvalues runs on every return, though fib captures nothing.

Where are push and pop?

run does 77.66% itself. Where are read_byte, push, pop and peek? With lto and optimization, rustc inlined them: their code was copied into run, so there’s no function left to charge. To see inside, I rebuilt with line tables, CARGO_PROFILE_RELEASE_DEBUG=line-tables-only. The instruction count stayed within 0.00001%: 20,186,276,675. Now callgrind_annotate splits run by the file each instruction came from (std paths trimmed):

6,464,842,447 (32.03%)  vm.rs
2,911,418,677 (14.42%)  vec/mod.rs
2,523,229,490 (12.50%)  slice/index.rs
1,060,055,003 ( 5.25%)  raw_vec/mod.rs

vec/mod.rs is Vec::push, pop and len; slice/index.rs is indexing, bounds check included; raw_vec is the capacity check in push. Per line, the top two are fetching the next byte:

1,328,801,331 ( 6.58%)
    let byte = self.chunk.code[self.ip];
1,224,288,866 ( 6.06%)
    self.ip += 1;

Flame graphs

A long list is hard to read as a shape. A flame graph draws one: each function on a stack is a box, stacked on its caller, and as wide as its share of the samples[2]. cargo flamegraph would make one from perf. Neither is installed here, so the diagram below is built from callgrind’s inclusive numbers by hand. Each box is as wide as its instructions, not its time.

Step throughA flame graph of drip-rs running fib.drip
Flame graph boxes: main, Vm::run, and the functions run called, with widths proportional to instructions executedfib.drip · 20,186,274,402 Irwidth = instructions while on the stack · up = deepermain: 20,185,973,025 Ir inclusive (100.0%)main 100.0%Vm::run: 20,185,841,592 Ir inclusive (100.0%)Vm::run 100.0%main and run: 100.0% each
fib.drip under callgrind: 20,186,274,402 machine instructions in all. The bottom box is main, then Vm::run on top of it. Each is as wide as the instructions spent while it was on the stack: all of them.
1/6

The input is folded stacks: one line per distinct stack, with a count, such as main;run;call_value;call 2418717018. Width means share; left-to-right order means nothing, and flamegraph.pl sorts the boxes alphabetically[2].

Definition

A flame graph draws a profile as stacked boxes. The bottom is the outermost function; each box sits on its caller. A box’s width is the share of samples (or counted cost) in which that function was on the stack at that spot. Its height is stack depth. The uncovered top of a box is its self cost.

Notice how short the tower is. fib recurses 35 deep in Drip, but Drip’s calls are entries in the VM’s frames vector, not Rust calls. A profile of drip-rs shows the VM. To profile the Drip program, the VM itself would have to count per Drip function, chapter 109’s instrumentation. drip-rs doesn’t.

Under the hood

Instructions are not time. callgrind’s default counts ignore cache misses (chapter 18), mispredicted branches (chapter 20) and how many instructions the CPU overlaps. In chapter 166, two loops differ by a factor of three in instructions and not at all in time. So treat a callgrind profile as a map of where the work is, and confirm every change with a timing. Recursion has its own trap: in a cycle of calls, inclusive costs stop meaning anything[1]. drip-rs avoids that, because its Rust stack never recurses.

Instrumenting is the other road: make the program count for itself. Here is a toy VM tallying its opcodes:

Try itProfiling by counting: a toy VM tallies its opcodes
#[derive(Clone, Copy, Debug)]
enum Op {
    Push(f64),
    Add,
    Jump(usize),
    Halt,
}

fn add(st: &mut Vec<f64>) {
    let b = st.pop().expect("b");
    let a = st.pop().expect("a");
    st.push(a + b);
}

fn main() {
    // A loop that never ends by itself:
    // the counter stops it.
    let code = [
        Op::Push(1.0),
        Op::Push(2.0),
        Op::Add,
        Op::Jump(0),
        Op::Halt,
    ];
    let mut counts = [0u64; 4];
    let mut st: Vec<f64> = vec![];
    let mut ip = 0;
    for _ in 0..1000 {
        let op = code[ip];
        ip += 1;
        let k = match op {
            Op::Push(_) => 0,
            Op::Add => 1,
            Op::Jump(_) => 2,
            Op::Halt => 3,
        };
        counts[k] += 1;
        match op {
            Op::Push(v) => st.push(v),
            Op::Add => add(&mut st),
            Op::Jump(t) => {
                st.clear();
                ip = t;
            }
            Op::Halt => break,
        }
    }
    let total: u64 =
        counts.iter().sum();
    let names =
        ["Push", "Add", "Jump", "Halt"];
    for k in 0..4 {
        let n = names[k];
        let c = counts[k];
        let p = 100.0 * c as f64
            / total as f64;
        println!("{n:5}{c:5}{p:6.1}%");
    }
}
Open in Rust Playground ↗

Your turn

ExerciseRead callgrind_annotate’s output

Write parse_row(line), which turns one line of callgrind_annotate output into a Row, or returns None for anything else (headers, rules, blank lines). A row is: the instruction count with commas, then the percentage in parentheses (there may be a space after (), then the name. Clean the name: drop a trailing [binary path] if there is one, and a leading ???:. Then write sum_named(rows, needle), which adds up the ir of every row whose name contains needle. With line tables, one function is split across many files, and this puts it back together.

// A line of callgrind_annotate output.
#[derive(Debug, PartialEq)]
struct Row {
    ir: u64,
    pct: f64,
    name: String,
}

fn parse_row(line: &str)
    -> Option<Row> {
    todo!()
}

fn sum_named(rows: &[Row], needle: &str)
    -> u64 {
    todo!()
}
Open in Rust Playground ↗
Extra practiceFolded stacks to flame-graph widths

flamegraph.pl reads folded stacks: one line per distinct stack, frames joined by ;, then a count. Write inclusive(folded), which returns the width of every box: for each stack prefix (such as "main;run"), the sum of the counts of every line that starts with that prefix. The test data are fib’s self costs from the chapter’s callgrind run.

use std::collections::HashMap;

fn inclusive(folded: &[(&str, u64)])
    -> HashMap<String, u64> {
    HashMap::new()
}
Open in Rust Playground ↗

What you learned

  • Profiling measures where the work goes. Sampling profilers estimate time; callgrind counts instructions exactly, about 60× slower.
  • Self cost is a function’s own instructions; inclusive adds its callees. Check a profile against counts you know: 29,860,703 calls.
  • Inlining folds small functions into their callers. Line tables bring the lines back without changing the code.
  • A flame graph stacks boxes by call depth, width by share. Order means nothing. drip-rs’s graph is flat because Drip calls aren’t Rust calls.
  • Instruction counts are steady but are not time.

Check your understanding

0/6
  1. 1.

    callgrind reports Table::get at 5.92% of fib’s instructions. What can you conclude about time?

  2. 2.

    Two callgrind runs of zoo.drip reported 7,528,846,449 and 7,528,846,360 instructions. What does that show?

  3. 3.

    A function’s inclusive cost is 15.5% and its self cost is 3.6%. Where did the other 11.9% go?

  4. 4.

    In a flame graph, one box sits on top of run and is 6% of the width. What is it?

  5. 5.

    Why does the release build’s profile show only five functions for all of fib?

  6. 6.

    Which of these would show you which Drip function is slow, rather than which part of drip-rs?

References

  1. [1] Josef Weidendorfer, The Valgrind Developers. Callgrind: a call-graph generating cache and branch prediction profiler (Valgrind User Manual, chapter 6). Valgrind documentation; source in callgrind/docs/cl-manual.xml of the Valgrind repository. valgrind.org/docs/manual/cl-manual.html (checked 2026-10-31)
  2. [2] Brendan Gregg. FlameGraph: stack trace visualizer (README and flamegraph.pl). github.com/brendangregg/FlameGraph. github.com/brendangregg/FlameGraph (checked 2026-10-31)