Lab 7 · reveal · 19 steps · 8 commits
Where does a program spend its time? A sampling profiler answers without changing the
program: every so often, it stops the program, writes down where it was, and lets it go
on. After a few hundred samples, the places that come up most are the places where the
time goes. In this lab you build one for xv6. A process asks the kernel to sample it, the
kernel counts the interrupted program counters in a histogram of address buckets, and a
new command, prof grep ..., prints the hottest functions of any program.
The hardware already stops every program ten times a second: the timer interrupt that
drives the scheduler. The design questions are about using that interrupt well. Which
piece of code sees every tick on every hart, and which register says where the
program was? Where should a histogram live, who may touch it while an interrupt handler
is updating it, and what happens to it across fork, exec and exit? A program run
under the profiler does not know it is being profiled: how do the samples get out? And
when you point the same machinery at the kernel itself, what can it see, and what is it
structurally unable to see?
The reference solution is eight small commits. It finds the inner loop of grep’s regular
expression matcher from 61 samples, and its kernel profile of usertests turns up a
surprise about which kernel code a timer-driven profiler can see at all.
Each step shows one change on the branch ext/07-profiler, the code around it, and the state of the machine when that code runs.
kernel/prof.hStep 1 of 19 · commit 1: Add the profile and profget system calls
The story of this tour follows proftest (pid 3, which gdb found on hart 1 when it
stopped at a sample) through the reference branch: it asks to be profiled, spins in a
hot loop, reads its histogram, and later exits with a profile; at the end, prof -k
profiles the kernel while usertests runs. Machine states marked as observed come from
gdb stops in recorded runs; the others follow from the code.
The first commit adds the two system calls (numbers 23 and 24, wired up exactly as in
lab 1) and this header, which the kernel and user programs share, so profget can copy
the page out byte for byte. A 32-byte header records the range [lo, hi), the bucket
size 1 << shift, the number of buckets in use, and two counters: every sample, and
the samples that fell outside the range. Then 1016 four-byte buckets: 32 + 4 × 1016 =
4096. One page from kalloc holds the whole histogram, and nothing has to be
allocated at interrupt time.
Why nout: a sample outside the range is still information (“12% of the time the
program was somewhere you did not ask about”), and counting it separately is what lets
the sample path refuse an out-of-range index (clinic 3).
kernel/syscall.cStep 2 of 19 · commit 1: Add the profile and profget system calls
profile and profget are registered like any system call: an extern prototype and
a designated-initializer entry each; SYS_profile is 23 and SYS_profget 24 in
kernel/syscall.h, and user/usys.pl generates the two stubs. The handlers live in a
new file, kernel/prof.c, added to OBJS in the Makefile, and at this commit both
return -1.
The arguments are two 64-bit addresses and an int: argaddr for lo and hi,
argint for shift. Addresses, not pointers into user memory: the kernel never
dereferences them, it only compares pcs against them, so no copyin is involved.
profget is the one call that writes user memory, and it will use copyout.
kernel/prof.cStep 3 of 19 · commit 2: Give each profiled process a histogram page
proftest calls profile(0, 4096, 3): its own first page of text, 8-byte buckets,
512 of them. sys_profile checks the arguments first: a non-empty range, a shift
between 0 and 31 (user code computes the bucket size as 1 << shift, an int), and at most
PROFBUCKETS buckets. (hi - lo - 1) >> shift is the index of the last byte’s bucket,
so the test is exact even when the range is not a multiple of the bucket size.
The page comes from kalloc (which takes and releases kmem.lock: noff 1 for a
moment, back to 0), is zeroed, and gets its header. Then the old histogram, if any, is
freed and the new one installed. profile(0, 0, 0) takes the same path with pr = 0:
stop and free.
No lock anywhere. At this commit only the process itself ever touches p->prof, and it
is here, in a system call, so nothing else can be using the page being freed.
kernel/proc.hStep 4 of 19 · commit 2: Give each profiled process a histogram page
p->prof goes at the end of the section “private to the process, so p->lock need not
be held”, next to sz and trapframe. The rule for that section is that only the
process itself (or code that runs before it can run, or after it is dead) touches the
field. Check it against every user of p->prof on this branch: profile, profget
and the exit write-out run as the process; the sample runs in the process’s own trap
handler; freeproc runs on a zombie. None of these can overlap.
Eight bytes per slot is the whole cost to processes that never profile. A histogram in the struct itself would have added 4 KiB to each of the 64 slots.
wait_lockthe child's p->lockkernel/proc.cStep 5 of 19 · commit 2: Give each profiled process a histogram page
Jump ahead: proftest reaps a child that was profiling (the one that checks that
exec keeps the profile). kwait holds wait_lock and the zombie’s p->lock, so
noff is 2 and interrupts are off; intena is 1 because the first acquire happened
in a system call with interrupts on. The page is freed next to the trapframe, and the
kfree takes kmem.lock for a moment (noff 3, the deepest nesting in the kernel,
which kwait already reached before this lab).
Two decisions sit in these three lines. Where: with the slot’s other resources,
when the slot is recycled; not in exec (the profile must survive it) and not at the
top of kexit (later commits still need the page there). Clearing the pointer: the
slot will be handed out again by allocproc, and that is the only reason a forked
child starts with no profile. kfork copies nothing; the child gets 0 because this
line wrote it. Clinic 4 shows a slot that kept its pointer.
kernel/trap.cStep 6 of 19 · commit 3: Sample the user pc on every timer interrupt
This is the profiler. A timer interrupt arrived while proftest was in user mode on
hart 1; uservec saved the registers and switched to the kernel stack and page
table; usertrap saved sepc in p->trapframe->epc (line 52), and devintr saw
scause 0x8000000000000005 (supervisor timer), called clockintr to re-arm this
hart’s timer, and returned 2. Now, before yield() gives the hart away, one if counts
the sample.
Observed (gdb, breakpoint at the sample): thread 2 = hart 1, proftest pid 3, sepc
and p->trapframe->epc both 0x112, sstatus 0x200000020 (SIE 0, SPIE 1, SPP 0),
noff 0. 0x112 is the mulw at the top of hot()'s inner loop: the instruction
that had not run yet when the interrupt arrived. (A loop head is a typical place for
a sample under QEMU, which takes interrupts between blocks of translated code.)
Why here: the branch runs exactly once per timer interrupt that found some process in
user mode, on every hart, and p is that process. Why trapframe->epc and not
r_sepc(): the saved copy belongs to the process; the register belongs to the hart,
and after yield() it holds someone else’s pc (clinic 2). Why before yield(): after
it, the process may have been switched out for a long time, and the moment being
sampled is now.
Interrupts stay off for the whole branch: the trap cleared SIE and this path never
calls intr_on.
kernel/prof.cStep 7 of 19 · commit 3: Sample the user pc on every timer interrupt
profsample counts the sample, checks the range, and adds one to bucket
(pc - lo) >> shift. In the observed stop, pc was 0x112, the range [0, 0x1000)
with shift 3, and nsample still 0: the first sample this histogram received. 0x112 >> 3 is bucket 34, which covers 0x110–0x117.
The range check is not optional. pc - lo is unsigned: for a pc below lo it wraps to
a number near 2^64, and for a pc above hi the index runs past the buckets in use and
soon past the page. Either way the increment can land in memory the profiler does not
own. Clinic 3 removed these four lines: thousands of increments went into neighbouring
pages of a running system without anyone noticing, and with a different bucket size
the kernel faulted on an address near 2^63.
No lock and no atomic instruction: a plain load, add and store. That is safe because this histogram is written only here, by the one hart running its process, with interrupts off; and read only by that same process in a system call, which cannot be running at the same time.
Step 8 of 19 · commit 3: Sample the user pc on every timer interrupt
clockintr looks like the natural home for a profiler: it runs at every tick. But
look at the if: the kernel’s notion of time, ticks, is advanced only by hart 0,
under tickslock, because it is a single wall clock for pause and uptime. Hart 1,
where proftest runs in this story, skips the block and only re-arms its own timer at
line 183.
So code placed next to ticks++ sees one hart’s ticks, not the profiled process’s.
Clinic 1 did exactly that, carefully checking SPP to keep only user-mode ticks. Its
proftest hot loop got 30 samples on hart 0, and 0 on the other harts.
clockintr is also called from kerneltrap, for interrupts that land in the
kernel. It cannot tell the two cases apart without reading sstatus itself, while
usertrap knows by construction.
kernel/prof.cStep 9 of 19 · commit 4: Copy the histogram out with profget
proftest’s hot loop has spun for 30 ticks; it calls profget(&pr). One
copyout of 4096 bytes, translated page by page through proftest’s page table, into
its static struct prof pr in .bss. No process without a profile gets anything (-1).
Interrupts are on (this is a system call), so a timer interrupt can arrive in the middle
of the copy. It would land in kerneltrap, not usertrap, so it does not touch
the histogram being copied; at this commit, nothing samples kernel time.
The test then counts the buckets that fall inside hot(). In the recorded runs on the
branch: between 28 and 30 samples per run, and all or all but one of the in-range
samples in hot().
kernel/proc.cStep 10 of 19 · commit 5: Write the profile to prof.out at exit
A program run under prof never calls profget; it just exits. So kexit saves the
histogram first thing, while the process still has everything file I/O needs: no lock
held (noff 0), interrupts on, and its current directory, which line 351 is about to
give up with iput.
Placement is the whole lesson of this step. Everything after line 355 runs under
wait_lock and then p->lock: no sleeping allowed. And freeproc, later, runs in
the parent under two spinlocks; clinic 5 put the write there and panicked in acquire
on the first reap.
The state is reasoned, not observed. The hart is illustrative: an exiting process can be on any hart.
kernel/prof.cStep 11 of 19 · commit 5: Write the profile to prof.out at exit
profdump does what open("prof.out", O_CREATE|O_WRONLY) and write would, without
the file descriptor: inside one log transaction (begin_op / end_op), create
(made non-static in sysfile.c for this) finds or creates prof.out relative to the
process’s current directory and returns it locked; writei with user_src = 0
copies 4096 bytes from kernel memory; iunlockput drops it.
Four data blocks, an inode, a bitmap block and a directory block fit easily in one transaction. Any of these calls may sleep (log space, a sleep-lock, the disk), which is allowed here and only here in the exit path.
create on an existing prof.out returns the old inode, and the write overwrites its
first 4096 bytes from offset 0. The parent can read it as soon as wait returns: the
write finished before the child became a zombie. Two caveats. A full disk makes
writei stop short: a run on a full disk printed balloc: out of blocks, left a
0-byte prof.out, and prof printed prof: no prof.out, with no hang or panic. And a larger prof.out left by someone
else keeps its tail, which prof ignores because it reads only one page.
user/proftest.cStep 12 of 19 · commit 6: Add proftest, a test program for the profiler
hot(n) spins for n ticks of uptime(), doing a million multiply-adds between
checks. It is noinline so that it has an address of its own; the check is “at least
90% of the in-range samples landed in hot(), and at least 10 samples in all”.
Where does hot() end? The first try used a marker function placed after it in the
source, but the compiler placed that marker at address 0, before hot: GCC does not
promise source order. hotend() takes the nearest of several known functions above
hot instead. In this build hot starts at 0xd0 and the next function at 0x136.
Each check prints OK or FAIL, and the final ALL OK covers only what was checked.
The tests also cover the lifetime rules: a forked child’s profget must fail, a child
that calls profile and then execs proftest exec must still have it, and 20
children that exit while profiling must not change the free-page count.
user/prof.cStep 13 of 19 · commit 7: Add prof, which runs a command under the profiler
prof reads the command’s ELF program headers (textrange, above) and takes the
loadable segment marked executable: for grep, [0, 0xb71), its text and read-only
data. The loop picks the smallest shift, starting at 1, for which the range fits in
1016 buckets: 2-byte buckets would resolve every compressed instruction, but grep
would need 1,465 of them, so it gets 4-byte buckets.
Then the fork-profile-exec sequence the lifetime rules were designed for: the child
calls profile for itself, then execs the command, which keeps the profile; the
parent waits, prints the elapsed ticks, and reads prof.out, written by the child’s
kexit. Between profile returning and the exec system call, the child runs a
handful of prof’s own instructions; a sample there would be counted at a prof
address. That window is far shorter than a tick.
The elapsed ticks are the yardstick for the report: in the recorded grep .*.*.*z b1,
72 ticks and 61 samples. The difference is time grep did not spend in user mode:
exec, reading its input, the write-out at exit.
MakefileStep 14 of 19 · commit 7: Add prof, which runs a command under the profiler
The Makefile already writes user/grep.sym for every program: objdump -t reduced to
“address name” lines. These lines copy them into fs.img (mkfs strips the user/
prefix, so the file is /grep.sym; the longest name, usertests.sym, fits in
DIRSIZ). forktest is linked by its own rule and has none. The empty pattern rule
tells make that a .sym file is produced by linking its program.
prof names a bucket by the function with the highest address at or below the
bucket’s start. With 4-byte buckets that is nearly exact. With coarse buckets a bucket
can straddle two functions and is named after the first: in the 32-byte kernel profile
in Measure, the bucket at release+0x30 is almost all memset’s loop.
stack0hart 1’s stack0 slice, with a kernelvec frame on topkernel/trap.cStep 15 of 19 · commit 8: Add a kernel profile, fed by kerneltrap on every hart
The last commit points the same idea at the kernel. A profile whose lo is at or
above KERNBASE is a kernel profile, fed from kerneltrap: on every timer
interrupt taken in supervisor mode, on every hart, sepc (read at the top, line 146) is
the kernel pc the interrupt landed in front of.
Observed (gdb, prof -k grep .*.*.*z README > o, breakpoint on kprofsample once the
profile existed): hart 1, no process (cpus[1].proc was 0), pc 0x80001e22,
sstatus 0x200000120 (SPP 1: from supervisor mode; SPIE 1; SIE 0), noff 0. Hart 1
was idle: 0x80001e22 is the csrci sstatus,2 of scheduler's intr_off(), the
instruction right after intr_on(). An idle hart sleeps in wfi with interrupts off;
the timer wakes it, the loop turns interrupts on, and the pending interrupt is taken
at once, in front of that one instruction. Every idle tick of every hart lands there.
This is the important difference from the user path: a kernel interrupt is taken only where the kernel had SIE on. More on that in the last step.
stack0with a kernelvec frame on topkproflockkernel/prof.cStep 16 of 19 · commit 8: Add a kernel profile, fed by kerneltrap on every hart
A kernel profile is written by every hart’s kerneltrap, possibly at the same
moment, so the increments need a lock: kproflock, a new spinlock. It is taken in
interrupt context, like tickslock, which is safe for the same reason: an interrupt
is only ever taken at noff 0, so no hart can be interrupted while holding it and then
try to take it again in the handler. Nothing that runs in interrupt context is
acquired while holding it, so it cannot deadlock. It is not a leaf lock, though:
profget holds it across a copyout, which may call kalloc (and kmem.lock)
for a lazily allocated destination page. (tickslock is not a leaf either:
clockintr calls wakeup under it.)
Observed after the acquire (same gdb stop, two nexts later): noff 1, intena 0
(interrupts were already off in the trap handler), kproflock locked by cpus+128,
hart 1’s struct cpu.
profdetach, below, is the other half: before a kernel histogram is freed or written
out, its owner unhooks it under the same lock. Once kprof is 0, no hart can be
inside profsample on that page, so kfree cannot race with an increment on another
hart. (An atomic add would make the increments safe, but not the free.)
kproflockStep 17 of 19 · commit 8: Add a kernel profile, fed by kerneltrap on every hart
sys_profile now has two kinds of histogram. The old one is unhooked (profdetach)
before it is freed. A new kernel histogram is published in kprof under the lock, and
only if no other process owns one; the loser frees its page and gets -1 (proftest
checks this with a forked child). A user histogram is installed exactly as before.
The page still belongs to the process (p->prof), so the lifetime rules stay the same:
profget copies it (now under kproflock, so the copy is a consistent snapshot while
other harts add to it), kexit writes it (after profdetach), and freeproc
frees it.
State: inside the critical section (lines 102 to 110) during a system call, so noff
1 and intena 1; interrupts come back on at the release. Reasoned from the code.
Step 18 of 19 · commit 8: Add a kernel profile, fed by kerneltrap on every hart
One condition is added to the user sample: p->prof->lo < KERNBASE. Without it, the
process that owns the kernel profile would add its own user pcs to the kernel
histogram, without kproflock, at the same time as other harts’ locked increments: a
data race on nsample, and user addresses counted as “outside” in a kernel profile.
With it, the two paths split cleanly: a user histogram is written only here, by its
own process, without a lock; the kernel histogram only in kerneltrap, by any hart,
under the lock.
Step 19 of 19 · commit 8: Add a kernel profile, fed by kerneltrap on every hart
The measurement that makes this lab worth doing: prof -k 0x80000a26 0x80000ca8 usertests -q profiled, at 2-byte resolution, the 642 bytes holding kfree,
kalloc, initlock, holding, push_off, acquire, pop_off and
release for the whole of usertests. These functions run hundreds of thousands of
times. Result: 242 samples, 240 of them on a single instruction, 0x80000c50. That
is the instruction after csrsi sstatus,2, which is line 115’s intr_on() in this
build. The other two were on the first instructions of kalloc and push_off, before
interrupts go off. Not one sample landed between a push_off and its pop_off.
The reason is the invariant from Locks and interrupt state: while noff > 0, SIE is
0, so a timer interrupt that arrives in a critical section stays pending, and is taken
the moment line 115 sets SIE. All the time spent inside critical sections, including
every spin in acquire waiting for another hart, is billed to this one instruction. In
the whole-kernel profile of usertests it was the hottest non-idle bucket: 265 of
3,467 samples, with the instruction after usertrap‘s intr_on() next (65:
trap-entry time). The idle harts’ time piles up at the matching instruction in
scheduler (3,110).
A profiler built on maskable interrupts sees only code that runs with interrupts on.
On x86, Linux’s perf delivers its samples as a non-maskable interrupt for exactly
this reason. On RISC-V, the performance-counter overflow interrupt (Sscofpmf) is
masked by SIE like any other, so a kernel sampling with it has the same blind spot.
Lab 7 · wrap-up
On the branch (ext/07-profiler, 8 commits), built with the project toolchain and run on
three harts (-smp 3 -m 128M), in one boot: proftest four times, usertests -q, then
proftest four more times. All eight proftest runs printed ALL OK. The first one and
the one right after usertests:
$ proftest
proftest: arguments, and profile(0, 0, 0) stops: OK
proftest: hot loop: 30 samples, 30 in range, 30 in hot()
proftest: most samples in the hot loop: OK
proftest: fork does not inherit the profile: OK
proftest: exec keeps the profile: OK
proftest: prof.out: 20 samples, 20 in range, 20 in hot()
proftest: exit writes prof.out: OK
proftest: only one kernel profile at a time: OK
proftest: kernel: 30 samples, 0 outside [0x80000000, 0x8000FE00)
proftest: kernel samples, none outside the profiled range: OK
proftest: free pages 32470 before, 32470 after
proftest: no leaks: OK
proftest: ALL OK
[...]
$ usertests -q
usertests starting
[...]
ALL TESTS PASSED
$ proftest
proftest: arguments, and profile(0, 0, 0) stops: OK
proftest: hot loop: 30 samples, 30 in range, 30 in hot()
proftest: most samples in the hot loop: OK
proftest: fork does not inherit the profile: OK
proftest: exec keeps the profile: OK
proftest: prof.out: 20 samples, 20 in range, 19 in hot()
proftest: exit writes prof.out: OK
proftest: only one kernel profile at a time: OK
proftest: kernel: 30 samples, 0 outside [0x80000000, 0x8000FE00)
proftest: kernel samples, none outside the profiled range: OK
proftest: free pages 32470 before, 32470 after
proftest: no leaks: OK
proftest: ALL OK
Over the eight runs the 30-tick hot loop got 30 samples every time, all inside hot(),
and the 20-tick child that leaves prof.out got 19 or 20. (A tick-driven count can
always come out one short: a 10-tick child required to get at least 10 samples was seen
to get 9, which is why this child spins for 20.) The idle second of
the kernel gave 30 to 33 samples: three harts, ten ticks each. usertests -q passing (its
usertrap() lines, trimmed here, are the expected kills of kernmem and friends) shows
that processes that never profile are unaffected, and the equal free-page counts show
that profiled processes leave no page behind. usertests -q also passed under prof -k
(in both kernel runs of Measure), with the kernel profile active on all three harts
throughout. In the same build, /prof /grep .*.*.*z /README and /prof -k /echo hi run
from a subdirectory found their .sym files, which prof opens from /.
Every commit builds on its own with make kernel/kernel fs.img (only the two existing
warnings about RWX segments).
Keys: ← → step · Home start