Lab 10: Trace a Wakeup
Background
A process blocks reading a pipe. Another process writes to it. Microseconds later the first process is running on a CPU. Between those two events the kernel made half a dozen decisions, and every one of them is instrumented.
This lab makes you follow one wakeup, end to end, naming every participant: who was woken, by whom, which CPU was chosen and why, how long until it actually ran, and what it displaced. Then you change the conditions and watch the decisions change.
There is almost no code to write. The deliverable is a trace you can explain line by line, which is a different and more useful thing than a program.
Why This Lab Matters
- Wakeup latency is what users experience as "slow", and this is how you measure it rather than guess.
- Placement decisions explain most real-world scheduler behavior, and they are invisible without tracing.
- The tools here —
schedtracepoints,perf sched,bpftrace— are what you will actually reach for when a production system misbehaves. - Being able to say "this task waited 14 ms runnable, on CPU 3, behind that task" is the difference between a bug report and a diagnosis.
Prerequisites
- Scheduling Classes and Placement, Balancing, and Power read.
- A lab guest with
-smp 4or more — placement is meaningless on one CPU. perfandbpftraceworking in the guest (warm-up exercises 9–11).CONFIG_SCHEDSTATS=yhelps; checkgrep CONFIG_SCHEDSTATS ~/kernel/build/.config.
Predict First
Write all six down.
- From
sched_wakeupto the matchingsched_switch, on an idle machine: nanoseconds, microseconds, or milliseconds? - The same, with every CPU running a busy loop.
- Two processes ping-ponging through a pipe: same CPU, same L3, or different sockets?
- Does the woken task run on the CPU that woke it, the CPU it last ran on, or neither?
- How many
sched_switchevents per second does an idle machine produce? (Not zero.) - When a task is woken on a different CPU than the waker, what makes that CPU notice?
The Target
writer KERNEL reader
────── ────── ──────
write(pipe) (blocked in read())
│ state: S
└──▶ pipe_write()
└──▶ wake_up_interruptible()
└──▶ try_to_wake_up(reader)
├── select_task_rq_fair() ◀── WHICH CPU?
│ wake_affine / select_idle_sibling
├── enqueue on that rq
└── if that CPU != this CPU:
send an IPI ──────────────▶ that CPU
│
[TRACEPOINT: sched_wakeup]
│
...time passes... ◀── HOW LONG?
│
__schedule() on the target CPU
pick_next_task() → reader
[TRACEPOINT: sched_switch]
│
reader runs
Your job is to produce that timeline for one real wakeup, with real numbers.
Step-by-Step Tasks
Step 1: The workload
A minimal, deterministic ping-pong so there is exactly one wakeup to look at.
// SPDX-License-Identifier: GPL-2.0
/* pingpong.c -- two processes waking each other through pipes. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char **argv)
{
int n = argc > 1 ? atoi(argv[1]) : 10000;
int a[2], b[2];
char c = 'x';
if (pipe(a) || pipe(b))
return 1;
if (fork() == 0) { /* child: the READER first */
for (int i = 0; i < n; i++) {
if (read(a[0], &c, 1) != 1) _exit(1);
if (write(b[1], &c, 1) != 1) _exit(1);
}
_exit(0);
}
for (int i = 0; i < n; i++) { /* parent: the WRITER first */
if (write(a[1], &c, 1) != 1) return 1;
if (read(b[0], &c, 1) != 1) return 1;
}
wait(NULL);
printf("done: %d round trips\n", n);
return 0;
}
gcc -O2 -o pingpong pingpong.c
./pingpong 100000
Step 2: The raw tracepoints
Start with tracefs, because it works on a kernel too broken to run anything else.
T=/sys/kernel/tracing; [ -d "$T" ] || T=/sys/kernel/debug/tracing
sudo sh -c "echo 0 > $T/tracing_on; echo > $T/trace"
sudo sh -c "echo 1 > $T/events/sched/sched_wakeup/enable"
sudo sh -c "echo 1 > $T/events/sched/sched_switch/enable"
sudo sh -c "echo 1 > $T/events/sched/sched_migrate_task/enable"
# Filter to just our processes, or the trace is unreadable:
sudo sh -c "echo 'comm ~ \"pingpong\"' > $T/events/sched/sched_wakeup/filter"
sudo sh -c "echo 1 > $T/tracing_on"
./pingpong 20
sudo sh -c "echo 0 > $T/tracing_on"
sudo head -40 "$T/trace"
You are looking at lines like:
pingpong-812 [002] d..5. 4231.882417: sched_wakeup: comm=pingpong pid=813 prio=120 target_cpu=003
<idle>-0 [003] d..3. 4231.882431: sched_switch: prev_comm=swapper/3 prev_pid=0 prev_prio=120
prev_state=R ==> next_comm=pingpong next_pid=813 next_prio=120
Read every field. For that pair:
| Field | Says |
|---|---|
pingpong-812 [002] | The waker: pid 812, running on CPU 2 |
4231.882417 | The timestamp of the wakeup |
target_cpu=003 | The scheduler chose CPU 3 for the woken task |
<idle>-0 [003] | On CPU 3, the task being switched away from is the idle task |
4231.882431 | The switch — 14 µs after the wakeup |
prev_state=R | The idle task was runnable (it always is) |
That 14 µs is the wakeup latency, and reproducing that measurement is the core of this lab.
Step 3: Measure the latency properly
One number is an anecdote. Get a distribution.
sudo bpftrace -e '
tracepoint:sched:sched_wakeup /str(args.comm) == "pingpong"/ {
@wake[args.pid] = nsecs;
}
tracepoint:sched:sched_switch /@wake[args.next_pid]/ {
@latency_us = hist((nsecs - @wake[args.next_pid]) / 1000);
delete(@wake[args.next_pid]);
}' &
sleep 1; ./pingpong 200000; sleep 1; kill %1
PREDICT FIRST: where will the histogram peak — the 1–2 µs bucket, 4–8 µs, or 64–128 µs?
Then load the machine and do it again:
for i in $(seq "$(nproc)"); do sh -c 'while :; do :; done' & done
sudo bpftrace -e '...same script...' &
./pingpong 200000
jobs -p | xargs kill
PREDICT FIRST: how far does the distribution move? Does the peak move, or does a long tail appear? Those are different diagnoses.
Step 4: Answer "which CPU, and why"
# Where do wakeups land, relative to the waker?
sudo bpftrace -e '
tracepoint:sched:sched_wakeup /str(args.comm) == "pingpong"/ {
@same_cpu[cpu == args.target_cpu] = count();
@target[args.target_cpu] = count();
}' &
./pingpong 100000; kill %1
# And the topology to interpret it against:
lscpu | grep -E 'Thread|Core|Socket|NUMA'
cat /sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list
cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
PREDICT FIRST: what fraction of wakeups place the target on the waker's own CPU? Explain your
answer using wake_affine and the fact that the waker immediately blocks in read().
Now change the answer deliberately:
taskset -c 0 ./pingpong 100000 # both pinned to one CPU
taskset -c 0,1 ./pingpong 100000 # two CPUs
taskset -c 0,7 ./pingpong 100000 # pick two that do NOT share an L3
Time each and explain the ordering.
Step 5: perf sched
sudo perf sched record -- ./pingpong 100000
sudo perf sched latency --sort max | head -20
sudo perf sched timehist | head -40
perf sched latency gives you, per task: average and maximum time spent runnable but not
running. That is the number to quote in a bug report — not CPU usage, not load average.
Task | Runtime ms | Switches | Avg delay ms | Max delay ms
pingpong:813 | 43.221 | 100000 | 0.014 | 2.481
^^^^^^^^^^ ^^^^^^^^^^
typical wakeup latency the tail
Step 6: Watch it in GDB
Tracing shows you what. GDB shows you where.
~/kernel-labs/scripts/gdb-attach.sh try_to_wake_up
(gdb) continue
... in the guest: ./pingpong 1
(gdb) bt
(gdb) p p->comm
(gdb) p p->__state
(gdb) p p->wake_cpu
(gdb) finish ← where did select_task_rq_fair send it?
(gdb) lx-ps
PREDICT FIRST: what will bt show above try_to_wake_up? Name the three or four frames between
it and the write() syscall before you look.
Warning: A breakpoint here stops the whole machine, including its clock. Timers and watchdogs all expire at once when you continue, so anything you measure while stepping is meaningless. Use GDB to answer "which code runs", and tracing to answer "how long". Never the reverse.
Step 7: Change the conditions, predict each time
For each row, predict before measuring, then explain any surprise:
| Change | Wakeup latency | Placement | Round-trip time |
|---|---|---|---|
| Baseline, idle machine | |||
Machine loaded (nproc busy loops) | |||
| Both processes pinned to one CPU | |||
| Pinned to two CPUs sharing an L3 | |||
| Pinned to two CPUs not sharing an L3 | |||
Reader at nice -19, writer at nice 19 | |||
Reader at chrt -f 50 | |||
Reader in a cgroup with cpu.max = "10000 100000" |
The last row is the one worth dwelling on: the latency distribution grows a tail of roughly the cgroup period, which is the throttling failure mode from EEVDF and Fairness appearing in a measurement.
Implementation Requirements / Deliverables
-
pingpong.cbuilt and running. - A raw tracefs trace of at least one wakeup/switch pair, annotated field by field in your own words.
-
A latency histogram from
bpftrace, idle and loaded, with both predictions recorded. - The placement distribution: what fraction land on the waker's CPU, and an explanation.
-
perf sched latencyoutput, with the avg and max delay identified and explained. -
A GDB backtrace from
try_to_wake_upup to thewrite()syscall, with every frame named. - The eight-row conditions table, filled in, with predictions recorded before measurements.
-
A written explanation of the cgroup-throttling row that names
cpu.stat'snr_throttled. - One paragraph: how would you use these tools on a production machine where "some requests are occasionally slow"?
Expected Output
$ sudo perf sched latency --sort max | head -8
-------------------------------------------------------------------------------
Task | Runtime ms | Switches | Avg delay ms | Max delay ms
-------------------------------------------------------------------------------
pingpong:(2) | 86.443 ms | 200000 | avg: 0.014 ms | max: 2.481 ms
kworker/3:1:87 | 1.204 ms | 42 | avg: 0.008 ms | max: 0.061 ms
$ sudo bpftrace -e '...' # idle machine
@latency_us:
[1, 2) 12043 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2, 4) 71822 |...
[4, 8) 9310 |...
[8, 16) 412 |
[16, 32) 38 |
Loaded, the shape changes in a specific way:
@latency_us:
[2, 4) 8102 |@@@@@@
[4, 8) 31022 |@@@@@@@@@@@@@@@@@@@@@@@
[8, 16) 66431 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[16, 32) 21044 |@@@@@@@@@@@@@@@@
[32, 64) 2210 |@
[64, 128) 188 |
[128, 256) 11 | ← the tail is the interesting part
Debugging Steps
The trace file is empty
tracing_on was not set, or your filter matches nothing. Filters are on the event's fields, so
comm in a sched_wakeup filter is the woken task's name, not the waker's. Clear the filter and
look at raw output first.
The trace is gigabytes
You enabled sched_switch with no filter on a busy machine. Use trace_pipe with head, or filter,
or use bpftrace, which aggregates in-kernel and never writes the raw events.
bpftrace cannot find args.comm
Field names differ between tracepoints and kernel versions. Check:
sudo cat /sys/kernel/tracing/events/sched/sched_wakeup/format
sudo bpftrace -lv 'tracepoint:sched:sched_wakeup'
perf sched says "no schedstat"
CONFIG_SCHEDSTATS is off, or /proc/sys/kernel/sched_schedstats is 0:
sudo sysctl kernel.sched_schedstats=1
The latencies are absurd (milliseconds on an idle machine)
You are in a VM without KVM, so every guest instruction is emulated. Check ls -l /dev/kvm on the
host. The shape of the distribution is still informative; the absolute numbers are not.
Everything runs on one CPU
-smp 1. Rebuild the QEMU line. This lab is meaningless on one CPU.
GDB timings are nonsense
Expected — see the warning in step 6. The whole machine was stopped.
Experiment
CLAIM. Wakeup latency is not a property of the scheduler alone. It is a property of the scheduler plus the load, and the two produce different distribution shapes that mean different things.
METHOD. Collect the latency histogram under four conditions:
| Condition | What competes |
|---|---|
| Idle | Nothing |
nproc busy loops at nice 0 | Fair-class competition |
nproc busy loops at nice 19 | Very low weight |
One chrt -f 50 busy loop pinned to the reader's CPU | A higher class |
PREDICTION. Before measuring, sketch the four histograms. Specifically predict: which conditions move the peak, and which add a tail?
RESULT. The distinction is the point of the experiment:
- A shifted peak means everything is uniformly slower — contention.
- A long tail means most wakeups are fine and some are catastrophic — usually a priority/class/throttling interaction.
Those two symptoms have completely different causes and completely different fixes, and averages hide both. Write one sentence you would put in a bug report for each shape.
Test
Turn the measurement into something repeatable:
cat > ~/kernel-labs/scripts/wakeup-latency.sh <<'EOF'
#!/usr/bin/env bash
# Wakeup latency distribution for a named command, as a repeatable measurement.
# ./wakeup-latency.sh ./pingpong 200000
set -euo pipefail
COMM=$(basename "$1")
sudo bpftrace -e "
tracepoint:sched:sched_wakeup /str(args.comm) == \"$COMM\"/ { @w[args.pid] = nsecs; }
tracepoint:sched:sched_switch /@w[args.next_pid]/ {
@us = hist((nsecs - @w[args.next_pid]) / 1000);
delete(@w[args.next_pid]);
}
END { clear(@w); }" -c "$*"
EOF
chmod +x ~/kernel-labs/scripts/wakeup-latency.sh
~/kernel-labs/scripts/wakeup-latency.sh ./pingpong 200000
Verify it can show a difference: run it idle, then run it with nproc busy loops. If the two
histograms look the same, something is wrong with your setup — most likely -smp 1, or no KVM.
Challenge Extensions
-
Find the IPI. When a task is woken on a different CPU, that CPU is told via an inter-processor interrupt. Watch it:
grep -E 'Rescheduling|Function call' /proc/interruptsbefore and after, and traceipi:*events if your kernel has them. Then answer: when is the IPI avoided, and why? -
Measure
wake_affinedirectly. Put a kprobe onwake_affineand record its return value against the eventualtarget_cpu. How often does the decision it makes surviveselect_idle_sibling? -
Break it deliberately. Pin the reader and writer to CPUs on different NUMA nodes (if you have them) and measure the round-trip cost. Then measure it with the memory allocated on the wrong node too, and separate the two effects.
-
Compare against
SCHED_DEADLINE. Give the readersched_setattrdeadline parameters and measure the latency distribution. Explain the shape difference in terms of admission control. -
Write a
sched_extscheduler. If your kernel hasCONFIG_SCHED_CLASS_EXT, start fromtools/sched_ext/scx_simpleand modify the placement policy. Re-run this lab's measurements under it. This is the fastest path from "I readfair.c" to "I have changed scheduling policy and measured it". -
Reproduce the throttling tail. Put the reader in a cgroup with a small
cpu.max, produce the ~period-length latency tail, and then produce thecpu.statoutput that identifies it. Write it up as if it were a bug report you received — that write-up is a genuinely useful artifact.
Validation / Self-check
- Walk the path from
write()to the reader running, naming every function and the two tracepoints. - In a
sched_wakeupline, what dotarget_cpuand the bracketed CPU number mean, and why do they differ? - How do you compute wakeup latency from two tracepoints? What exactly is being measured?
- What does
perf sched latency's "delay" column measure, and why is it the right number for a bug report? - What fraction of your ping-pong wakeups landed on the waker's CPU, and what explains it?
- Why does pinning both processes to one CPU sometimes make the round trip faster?
- What makes a remote CPU notice that something was enqueued on it? When is that avoided?
- Distinguish a shifted peak from a long tail in a latency histogram. What does each imply?
- Why are GDB-derived timings meaningless, and what is GDB good for here instead?
- You have a production machine where "some requests are occasionally slow". Give the exact first three commands you would run, and what each would rule in or out.
Next: Memory Management — the other subsystem every driver touches.