"Avoid syscalls on the hot path" needs a number. Seven calls, one
million times each: a vDSO call that never enters the kernel, the
cheapest possible real syscall, reads and writes of one byte through
the VFS, a scheduler entry, and a path lookup. Then the same program
under strace, to see that the tool that counts syscalls makes each one
~100x slower.
evidence to leave with
vDSO 38 ns, getpid 142 ns, open+close 1.7 us; getpid 23.6 us under strace
first, get the code
run this
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
then build it
run this
$ cd systems && ./build.sh
produces bin/02_syscall_costwalk it, one step at a time
run this
$ ../tools/pin.sh -q ./bin/02_syscall_cost
what it printed on the machine that wrote this lab
clock_gettime(MONOTONIC) 37.9 ns vDSO: no kernel entry
getpid() 142.5 ns the cheapest real syscall: pure user<->kernel transition
syscall(SYS_getpid) 142.3 ns same, via the generic wrapper
read(/dev/zero, 1 byte) 217.7 ns transition + VFS + copy_to_user
write(/dev/null, 1 byte) 184.5 ns transition + VFS
sched_yield() 333.5 ns transition + scheduler (nothing else runnable)
open+close(/dev/null) 1667.0 ns path lookup + fd table: two syscalls
run this
$ strace -c ./bin/02_syscall_cost
what it printed on the machine that wrote this lab
- clock_gettime is ~38 ns and strace does not see it: the vDSO maps a
page of kernel code and data into every process, and the time is read
from that page (TSC + a scale factor) with no mode switch. Use it
freely for microsecond timing; use rdtsc for nanosecond timing
(perf lab 04: 12 ns).
- getpid is the floor: ~140 ns for the transition into the kernel and
back on this CPU with its mitigations (Spectre/Meltdown-era entry code
added a lot to this number across the industry; it used to be ~50 ns).
Anything a syscall does is on top of this.
- read/write of one byte: +40-75 ns for the VFS layer and the copy.
Real reads of real data are dominated by the data, not by this.
- sched_yield: 330 ns to enter the scheduler and find nothing to switch
to. A REAL context switch (another thread runs, caches cool, TLB
partially flushed) is 1-5 us of direct cost and more of indirect.
- open+close: 1.7 us. Path lookup (dcache), permission checks, fd table.
Anything with a path is expensive; keep files open.
- Under strace every syscall becomes a ptrace stop: two context switches
to the tracer and back. strace -c is for COUNTING what you call, never
for timing it. (Same for ltrace, lab 01.) For low-overhead syscall
accounting use `perf trace` or bpftrace (root), or just count with
strace and measure without it.
why this matters on a latency-critical path
140 ns is not the problem; a syscall's VARIANCE is. Entering the kernel
means the scheduler may run, an interrupt may be serviced, a lock may
be taken. That is why the hot path does no syscalls at all: no
logging (write), no clock_gettime when rdtsc will do, no
epoll_wait (busy-poll instead, lab 03), no malloc (mmap, lab 01), no
locking that can sleep (futex, concurrency lab 04).
the rules
Y1. Know the floor on your machine (getpid). Everything else is floor + work.
Y2. vDSO calls (clock_gettime, gettimeofday, getcpu) are not syscalls;
real syscalls in a hot loop show up in `strace -c` counts and in
`perf stat -e context-switches` when they sleep.
Y3. Count syscalls with strace; time them without it.
take it further
1. `perf stat -e syscalls:sys_enter_getpid ./bin/02_syscall_cost` (needs
tracepoint access; may require root here): the low-overhead way.
2. Boot parameter `mitigations=off` (root, a VM is a good place) and
rerun: getpid drops; that difference is the security tax.
3. Replace getpid with `gettid` via syscall(): same cost. Replace with
`getcpu()`: vDSO on x86-64: ~vDSO speed.
============================================================
read the source · systems/02_syscall_cost.c
#include"../tools/bench.h"#include<fcntl.h>#include<sys/syscall.h>#include<unistd.h>#defineREPS1000000staticvoidrow(constchar*name,uint64_t ns,constchar*note){printf("%-28s %8.1f ns %s\n", name,(double)ns / REPS, note);}intmain(void){uint64_t t0, acc =0;char c =0;structtimespec ts;int zero =open("/dev/zero", O_RDONLY), null =open("/dev/null", O_WRONLY); t0 =bench_ns();for(int i =0; i < REPS; i++){clock_gettime(CLOCK_MONOTONIC,&ts); acc += ts.tv_nsec;}BENCH_KEEP(acc);row("clock_gettime(MONOTONIC)",bench_ns()- t0,"vDSO: no kernel entry"); t0 =bench_ns();for(int i =0; i < REPS; i++) acc +=(uint64_t)getpid();BENCH_KEEP(acc);row("getpid()",bench_ns()- t0,"the cheapest real syscall: pure user<->kernel transition"); t0 =bench_ns();for(int i =0; i < REPS; i++) acc +=(uint64_t)syscall(SYS_getpid);BENCH_KEEP(acc);row("syscall(SYS_getpid)",bench_ns()- t0,"same, via the generic wrapper"); t0 =bench_ns();for(int i =0; i < REPS; i++) acc +=(uint64_t)read(zero,&c,1);BENCH_KEEP(acc);row("read(/dev/zero, 1 byte)",bench_ns()- t0,"transition + VFS + copy_to_user"); t0 =bench_ns();for(int i =0; i < REPS; i++) acc +=(uint64_t)write(null,&c,1);BENCH_KEEP(acc);row("write(/dev/null, 1 byte)",bench_ns()- t0,"transition + VFS"); t0 =bench_ns();for(int i =0; i < REPS; i++) acc +=(uint64_t)sched_yield();BENCH_KEEP(acc);row("sched_yield()",bench_ns()- t0,"transition + scheduler (nothing else runnable)"); t0 =bench_ns();for(int i =0; i < REPS /10; i++){int fd =open("/dev/null", O_WRONLY);close(fd);}printf("%-28s %8.1f ns %s\n","open+close(/dev/null)",(double)(bench_ns()- t0)/(double)(REPS /10),"path lookup + fd table: two syscalls");return0;}
◇
Finish the lab: write the evidence note
A lab is done when you can say what it showed on your machine — not when you have read it.
Which role are you preparing for?
It decides which problems come first, which drills open, which labs you see and how the Learn surface is organised. Change it any time from the switch in the top bar, or press Escape to look around first.