eBPF 실전 (4) — 런큐 지연과 컨텍스트 스위치 관측하기

CPU 사용률이 100%가 아닌데도 응답이 느린 서버가 있다. 태스크가 실행 가능(runnable) 상태로 런큐에서 기다린 시간, 즉 런큐 지연(run queue latency)이 원인인 경우가 많은데 top이나 vmstat에는 이 값이 나오지 않는다. /proc/PID/schedstat에 누적 합계가 있긴 하지만 분포를 볼 수 없어서, 평균은 멀쩡한데 가끔 수십 ms씩 밀리는 상황을 잡아내지 못한다.

이 글에서는 스케줄러 tracepoint에 BPF를 붙여 런큐 지연 히스토그램을 만들고, 우선순위와 스케줄링 정책을 바꿔가며 그 분포가 어떻게 달라지는지 측정한다. 이어서 컨텍스트 스위치를 자발/비자발로 나눠 세고, 태스크가 CPU 사이를 옮겨다니는 것까지 관측한다. 측정 결과는 모두 커널 6.8.0-139-generic, 6코어 VM에서 실행한 값이다.

이 시리즈의 다른 글

스케줄러 tracepoint

스케줄러는 필요한 지점마다 tracepoint를 제공한다. 이 글에서 쓰는 것만 정리하면 다음과 같다.

tracepoint인자언제 발생하는가
sched_wakeuptask_struct *p잠든 태스크가 깨어나 런큐에 들어갈 때
sched_wakeup_newtask_struct *p새로 만들어진 태스크가 처음 런큐에 들어갈 때
sched_switchpreempt, *prev, *nextCPU가 다른 태스크로 넘어갈 때
sched_migrate_task*p, dest_cpu태스크가 다른 CPU의 런큐로 옮겨갈 때

SEC("tp_btf/...")로 붙이면 인자를 task_struct * 포인터 그대로 받아 필드에 직접 접근할 수 있다. BTF가 없는 커널에서는 SEC("tracepoint/sched/...")로 붙이고 트레이스 레코드 구조체에서 값을 꺼내야 한다.

런큐 지연 히스토그램

sched_wakeup에서 시각을 기록하고 sched_switch에서 그 태스크가 CPU를 잡은 순간 차이를 히스토그램에 넣는다. 선점당한 태스크는 sched_switch 시점에도 여전히 TASK_RUNNING이므로, 그 자리에서 다시 대기 시작 시각을 찍어줘야 빠짐없이 센다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

#define TASK_RUNNING 0
#define MAX_SLOTS 26

const volatile __u32 target_tgid = 0;	/* 0이면 전체 */

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 65536);
	__type(key, u32);
	__type(value, u64);
} enq_ts SEC(".maps");

__u64 hist[MAX_SLOTS];
__u64 total_ns;
__u64 samples;

static __always_inline bool wanted(struct task_struct *p)
{
	return !target_tgid || p->tgid == target_tgid;
}

static __always_inline void mark_enqueue(struct task_struct *p)
{
	u32 pid = p->pid;
	u64 ts;

	if (!pid || !wanted(p))
		return;
	ts = bpf_ktime_get_ns();
	bpf_map_update_elem(&enq_ts, &pid, &ts, BPF_ANY);
}

SEC("tp_btf/sched_wakeup")
int BPF_PROG(on_wakeup, struct task_struct *p)
{
	mark_enqueue(p);
	return 0;
}

SEC("tp_btf/sched_wakeup_new")
int BPF_PROG(on_wakeup_new, struct task_struct *p)
{
	mark_enqueue(p);
	return 0;
}

SEC("tp_btf/sched_switch")
int BPF_PROG(on_switch, bool preempt, struct task_struct *prev, struct task_struct *next)
{
	u32 pid = next->pid;
	u64 *tsp, delta, us, slot = 0;

	/* 선점당한 태스크는 여전히 runnable: 다시 런큐 대기 시작 */
	if (prev->__state == TASK_RUNNING)
		mark_enqueue(prev);

	tsp = bpf_map_lookup_elem(&enq_ts, &pid);
	if (!tsp)
		return 0;
	delta = bpf_ktime_get_ns() - *tsp;
	bpf_map_delete_elem(&enq_ts, &pid);
	if ((s64)delta < 0)		/* 다른 CPU가 방금 갱신한 타임스탬프 */
		return 0;

	us = delta / 1000;
	while (us > 1 && slot < MAX_SLOTS - 1) {
		us >>= 1;
		slot++;
	}
	__sync_fetch_and_add(&hist[slot], 1);
	__sync_fetch_and_add(&total_ns, delta);
	__sync_fetch_and_add(&samples, 1);
	return 0;
}

유저 공간 로더는 전역 배열을 그대로 읽어 출력한다. target_tgid를 rodata로 두면 로드 전에 값을 정해 verifier가 필터 분기를 정리할 수 있다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <bpf/libbpf.h>
#include "runqlat.skel.h"

int main(int argc, char **argv)
{
	int sec = argc > 1 ? atoi(argv[1]) : 5;
	struct runqlat_bpf *skel = runqlat_bpf__open();

	if (argc > 2)
		skel->rodata->target_tgid = atoi(argv[2]);
	if (runqlat_bpf__load(skel) || runqlat_bpf__attach(skel))
		return 1;
	sleep(sec);

	unsigned long long max = 0;
	for (int i = 0; i < 26; i++)
		if (skel->bss->hist[i] > max)
			max = skel->bss->hist[i];
	printf("%-20s %-10s\n", "usecs", "count");
	for (int i = 0; i < 26; i++) {
		unsigned long long c = skel->bss->hist[i];
		if (!c)
			continue;
		printf("%8llu -> %-8llu : %-8llu |", i ? 1ULL << i : 0, (1ULL << (i + 1)) - 1, c);
		for (unsigned long long j = 0; j < c * 40 / max; j++)
			putchar('*');
		printf("\n");
	}
	printf("samples=%llu total_wait=%.3f ms avg=%.1f us\n", skel->bss->samples,
	       skel->bss->total_ns / 1e6,
	       skel->bss->samples ? skel->bss->total_ns / 1e3 / skel->bss->samples : 0);
	runqlat_bpf__destroy(skel);
	return 0;
}

한가한 상태와, CPU 0-1에 묶은 스피너 4개가 도는 상태를 각각 5초씩 측정했다.

$ sudo ./runqlat 5          # A1 한가한 상태, 시스템 전체
usecs                count
       0 -> 1        : 31       |****************
       2 -> 3        : 55       |****************************
       4 -> 7        : 24       |************
       8 -> 15       : 14       |*******
      16 -> 31       : 63       |********************************
      32 -> 63       : 78       |****************************************
      64 -> 127      : 25       |************
     128 -> 255      : 10       |*****
     256 -> 511      : 3        |*
     512 -> 1023     : 1        |
    1024 -> 2047     : 1        |
samples=305 total_wait=13.054 ms avg=42.8 us

$ for i in $(seq 4); do taskset -c 0,1 ./spin 8 & done   # A2 CPU 0-1에 스피너 4개
$ sudo ./runqlat 5
usecs                count
       0 -> 1        : 9        |
       2 -> 3        : 38       |**
       4 -> 7        : 35       |**
       8 -> 15       : 5        |
      16 -> 31       : 22       |*
      32 -> 63       : 48       |**
      64 -> 127      : 15       |
     128 -> 255      : 9        |
     256 -> 511      : 1        |
    1024 -> 2047     : 1        |
    2048 -> 4095     : 285      |*****************
    4096 -> 8191     : 273      |*****************
    8192 -> 16383    : 641      |****************************************
   16384 -> 32767    : 79       |****
   32768 -> 65535    : 12       |
samples=1474 total_wait=11356.530 ms avg=7704.6 us

평균은 42.8us에서 7704.6us로 올라갔지만, 더 중요한 것은 분포가 둘로 갈라졌다는 점이다. 수 us대 봉우리(경쟁이 없는 CPU 2-5의 태스크들)는 그대로 있고, 스피너와 CPU를 나눠 쓰는 태스크만 8~16ms 구간에 새 봉우리를 만들었다 — 평균만 봤다면 시스템 전체가 느려졌다고 오판했을 상황이다.

우선순위와 정책이 런큐 지연에 미치는 영향

1ms마다 깨어나 짧게 일하는 지연 민감 태스크(ticker)를 만들고, CPU 0-1을 스피너 4개와 나눠 쓰게 했다. runqlat에 PID를 넘겨 이 태스크만 측정한다.

/* ticker: 1ms마다 깨어나 짧은 일을 하는 지연 민감 태스크 */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <sys/resource.h>

static void schedstat(unsigned long long *run, unsigned long long *wait)
{
	FILE *f = fopen("/proc/self/schedstat", "r");
	if (fscanf(f, "%llu %llu", run, wait) != 2)
		*run = *wait = 0;
	fclose(f);
}

int main(int argc, char **argv)
{
	int n = argc > 1 ? atoi(argv[1]) : 3000;
	unsigned long long r0, w0, r1, w1;
	struct timespec ts = { 0, 1000000 };

	sleep(1);			/* 측정기가 붙을 시간 */
	schedstat(&r0, &w0);
	for (int i = 0; i < n; i++) {
		for (volatile int j = 0; j < 20000; j++)
			;
		nanosleep(&ts, NULL);
	}
	schedstat(&r1, &w1);
	struct rusage ru;
	getrusage(RUSAGE_SELF, &ru);
	printf("ticker pid=%d schedstat run_delay=%.3f ms  rusage nvcsw=%ld nivcsw=%ld\n",
	       getpid(), (w1 - w0) / 1e6, ru.ru_nvcsw, ru.ru_nivcsw);
	return 0;
}
run() {  # $1=라벨 $2=ticker 실행 래퍼 $3=스피너 수 $4=스피너 nice
  for i in $(seq $3); do taskset -c 0,1 nice -n $4 ./spin 14 >/dev/null 2>&1 & done
  sleep 0.5
  taskset -c 0,1 $2 ./ticker 2000 > /tmp/tk.txt &
  TP=$!; sleep 0.3
  ./runqlat 12 $TP | tail -1 | sed "s/^/$1  /"
  wait $TP; sed "s/^/$1  /" /tmp/tk.txt
  wait 2>/dev/null
}
run "B1 단독          " ""           0 0
run "B2 nice 0 경쟁   " ""           4 0
run "B3 SCHED_FIFO    " "chrt -f 50" 4 0
run "B4 경쟁자 nice 19" ""           4 19
--- run 1
B1 단독            samples=1712 total_wait=79.698 ms avg=46.6 us
B1 단독            ticker pid=2297 schedstat run_delay=44.326 ms  rusage nvcsw=2001 nivcsw=0
B2 nice 0 경쟁     samples=1899 total_wait=805.199 ms avg=424.0 us
B2 nice 0 경쟁     ticker pid=2309 schedstat run_delay=776.542 ms  rusage nvcsw=2001 nivcsw=2
B3 SCHED_FIFO      samples=1675 total_wait=73.278 ms avg=43.7 us
B3 SCHED_FIFO      ticker pid=2323 schedstat run_delay=22.019 ms  rusage nvcsw=2003 nivcsw=3
B4 경쟁자 nice 19  samples=1381 total_wait=168.049 ms avg=121.7 us
B4 경쟁자 nice 19  ticker pid=2335 schedstat run_delay=135.958 ms  rusage nvcsw=2001 nivcsw=2
--- run 2
B1 단독            samples=1736 total_wait=69.924 ms avg=40.3 us
B2 nice 0 경쟁     samples=1745 total_wait=1310.968 ms avg=751.3 us
B3 SCHED_FIFO      samples=1830 total_wait=85.666 ms avg=46.8 us
B4 경쟁자 nice 19  samples=2002 total_wait=127.699 ms avg=63.8 us
--- run 3
B1 단독            samples=664 total_wait=33.571 ms avg=50.6 us
B2 nice 0 경쟁     samples=1863 total_wait=633.541 ms avg=340.1 us
B3 SCHED_FIFO      samples=1836 total_wait=78.453 ms avg=42.7 us
B4 경쟁자 nice 19  samples=1931 total_wait=47.755 ms avg=24.7 us
설정BPF 평균 (3회)schedstat run_delay (run 1)해석
B1 단독46.6 / 40.3 / 50.6 us44.3 ms경쟁이 없으면 깨어나자마자 실행
B2 nice 0 경쟁424.0 / 751.3 / 340.1 us776.5 ms같은 가중치라 CFS가 순서대로 배분
B3 SCHED_FIFO43.7 / 46.8 / 42.7 us22.0 ms실시간 정책이 CFS 태스크를 즉시 선점
B4 경쟁자 nice 19121.7 / 63.8 / 24.7 us136.0 ms경쟁자 가중치를 낮추면 지연도 내려감

chrt -f 50으로 정책만 바꾼 B3는 경쟁자가 4개 그대로인데도 단독 실행(B1)과 같은 수준으로 돌아왔다. 반대로 nice 값만 조정한 B4는 실행마다 24.7~121.7us로 흔들리는데, CFS의 가중치는 지연 상한을 보장하지 않기 때문이다.

BPF 측정값과 커널이 직접 세는 schedstat의 대기 시간을 비교하면 계측이 맞는지 확인할 수 있다. 두 값은 경쟁이 심할수록 가까워지고(B2: 805.2 vs 776.5ms), 지연이 짧을 때는 BPF 쪽이 더 크게 나온다(B3: 73.3 vs 22.0ms).

  • BPF는 sched_wakeup이 찍히는 순간부터 잰다 — 깨우는 CPU가 대상 CPU에 IPI를 보내고 런큐에 넣기까지의 시간이 포함된다.
  • schedstat의 run_delay는 런큐에 실제로 들어간 뒤부터 센다.
  • 따라서 두 값의 차이는 웨이크업 경로 자체의 비용이고, 런큐 대기가 길어질수록 상대적으로 묻힌다.

자발/비자발 컨텍스트 스위치 구분하기

sched_switch 시점에 prev가 아직 TASK_RUNNING이면 더 돌 수 있는데 뺏긴 것(비자발), 아니면 스스로 잠든 것(자발)이다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

#define TASK_RUNNING 0

struct cs {
	char comm[16];
	u64 voluntary;
	u64 involuntary;
};

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 16384);
	__type(key, u32);			/* pid */
	__type(value, struct cs);
} counts SEC(".maps");

SEC("tp_btf/sched_switch")
int BPF_PROG(on_switch, bool preempt, struct task_struct *prev, struct task_struct *next)
{
	u32 pid = prev->pid;
	struct cs *c, zero = {};

	if (!pid)
		return 0;
	c = bpf_map_lookup_elem(&counts, &pid);
	if (!c) {
		bpf_probe_read_kernel_str(zero.comm, sizeof(zero.comm), prev->comm);
		bpf_map_update_elem(&counts, &pid, &zero, BPF_NOEXIST);
		c = bpf_map_lookup_elem(&counts, &pid);
		if (!c)
			return 0;
	}
	/* 스위치 시점에 여전히 RUNNING이면 선점(비자발), 아니면 스스로 잠든 것(자발) */
	if (prev->__state == TASK_RUNNING)
		__sync_fetch_and_add(&c->involuntary, 1);
	else
		__sync_fetch_and_add(&c->voluntary, 1);
	return 0;
}
### BPF tp_btf/sched_switch 집계 (8초)
PID     COMM              VOLUNTARY   INVOLUNT
2547    spin                      0       1275
2545    spin                      0        390
2544    spin                      0        384
2546    spin                      0        380
2548    ticker                 1274          0

### /proc/<pid>/status 교차 확인 (프로세스 시작~현재 누적)
PID     COMM        VOLUNTARY   INVOLUNT
2548    ticker           1401          1
2544    spin                0        459
2545    spin                0        471

nanosleep()으로 스스로 자는 ticker는 자발 스위치만, CPU를 놓지 않는 스피너는 비자발 스위치만 기록됐다. 측정 창(8초)과 프로세스 수명이 달라 절대값은 다르지만 비율은 /proc/PID/status와 일치한다.

웨이크업과 CPU 마이그레이션

같은 맵에 sched_migrate_task와 sched_wakeup을 함께 집계하면, 태스크가 CPU를 옮겨다니는 빈도를 깨어난 횟수와 나란히 볼 수 있다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

struct mig {
	char comm[16];
	__u64 migrations;
	__u64 wakeups;
};

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 16384);
	__type(key, __u32);			/* pid */
	__type(value, struct mig);
} stats SEC(".maps");

static struct mig *slot(__u32 pid, const char *comm)
{
	struct mig *m, zero = {};

	m = bpf_map_lookup_elem(&stats, &pid);
	if (m)
		return m;
	bpf_probe_read_kernel_str(zero.comm, sizeof(zero.comm), comm);
	bpf_map_update_elem(&stats, &pid, &zero, BPF_NOEXIST);
	return bpf_map_lookup_elem(&stats, &pid);
}

SEC("tp_btf/sched_migrate_task")
int BPF_PROG(on_migrate, struct task_struct *p, int dest_cpu)
{
	__u32 pid = p->pid;
	struct mig *m;

	if (!pid)
		return 0;
	m = slot(pid, p->comm);
	if (m)
		__sync_fetch_and_add(&m->migrations, 1);
	return 0;
}

SEC("tp_btf/sched_wakeup")
int BPF_PROG(on_wakeup, struct task_struct *p)
{
	__u32 pid = p->pid;
	struct mig *m;

	if (!pid)
		return 0;
	m = slot(pid, p->comm);
	if (m)
		__sync_fetch_and_add(&m->wakeups, 1);
	return 0;
}
### C1 스피너 8개, CPU 제한 없음 (6 CPU)
PID     COMM               MIGRATIONS    WAKEUPS
2774    spin                        2          0
2773    spin                        1          0
2770    spin                        5          0
2775    spin                        1          0
2772    spin                        4          0

### C2 같은 스피너 8개를 CPU 0-1에 묶었을 때
PID     COMM               MIGRATIONS    WAKEUPS

### C3 ticker: 깨어난 횟수 대비 마이그레이션
PID     COMM               MIGRATIONS    WAKEUPS
2792    ticker                      2       1022

C2가 빈 표인 것은 이벤트가 하나도 없어서다 — 8개를 CPU 2개에 균등하게 묶어두면 로드 밸런서가 옮길 이유가 없다. C3의 ticker는 6초 동안 1022번 깨어나면서 CPU를 2번만 바꿨는데, 웨이크업 시 캐시가 따뜻한 CPU를 먼저 고르기 때문이다.

주의사항

  • tp_btf/ 프로그램은 커널 BTF(/sys/kernel/btf/vmlinux)를 요구한다. 없는 커널에서는 tracepoint/sched/sched_switch로 붙이고 트레이스 레코드에서 prev_state 같은 필드를 읽어야 한다.
  • task_struct의 상태 필드는 커널 5.14에서 state에서 __state로 바뀌었다. CO-RE가 오프셋은 맞춰주지만 이름이 다르면 컴파일 단계에서 실패하므로, 여러 커널을 대상으로 한다면 bpf_core_field_exists()로 분기해야 한다.
  • 히스토그램 도구는 측정 창과 워크로드가 어긋나면 표본이 몇 건만 잡힌다. 위 B1의 run 3(samples=664)이 그런 경우다 — 측정기를 먼저 띄우고 워크로드를 시작할 것.
  • bpf_ktime_get_ns() 차이가 음수로 나올 수 있다. 다른 CPU가 같은 PID의 타임스탬프를 방금 갱신한 경우이므로 버려야 히스토그램이 오염되지 않는다.
  • enq_ts 같은 상태 맵은 태스크가 종료되면 항목이 남는다. 장시간 돌릴 도구라면 sched_process_exit에서 키를 지우거나 LRU 해시를 쓸 것.
  • SCHED_FIFO는 런큐 지연을 확실히 줄이지만, 우선순위가 낮은 태스크를 무한정 굶길 수 있다. /proc/sys/kernel/sched_rt_runtime_us가 최후의 안전장치다.

마무리

스케줄러 tracepoint 3개만으로 런큐 지연 분포, 컨텍스트 스위치 성격, CPU 마이그레이션까지 태스크 단위로 볼 수 있었다. 평균값 하나로는 보이지 않던 이중 봉우리가 히스토그램에서 바로 드러났고, schedstat과 /proc/PID/status로 교차 검증까지 마쳤다.

다음 편에서는 관측을 넘어 sched_ext로 스케줄링 정책 자체를 BPF 프로그램으로 구현한다.

참고

답글 남기기