QEMU 환경에서 발견한 문제와 수정 과정을 통해 커널 디버깅을 배운다
보충자료DPAS 패치는 USENIX FAST '26에 발표된 연구 결과를 Linux 커널 5.18에 적용한 것입니다. 논문의 연구 환경(실제 NVMe SSD, 특정 커널 버전)과 실습 환경(QEMU 가상 NVMe, GCC 14)은 다르기 때문에, 패치를 그대로 적용하면 빌드 오류, 런타임 비정상 동작이 발생할 수 있습니다.
이 문서는 dpas.patch를 QEMU 환경에 적용하면서 발견한 세 가지 핵심 문제와 수정 과정을 정리합니다. 각 문제의 증상 → 원인 분석 → 코드 수정 → 검증 과정은 실제 커널 개발에서의 디버깅 흐름과 동일합니다.
| 항목 | 논문 연구 환경 | QEMU 실습 환경 |
|---|---|---|
| 스토리지 | 실제 NVMe SSD | QEMU 가상 NVMe (RAM-backed) |
| 타이머 정밀도 | TSC 기반 sub-μs | 에뮬레이션 오버헤드로 수 μs |
| I/O 스케줄러 | none | none |
| WBT (Write Back Throttling) | 활성 (wbt_lat_usec=2000) | 미지원 (Invalid argument) |
| GCC 버전 | GCC 11-12 | GCC 14 (Ubuntu 24.04) |
DPAS를 활성화해도 모드 전환이 전혀 발생하지 않습니다.
switch_stat을 확인하면 pas io: 0, cp io: 0 — I/O가 DPAS 상태 머신에 진입하지 못합니다.
DPAS는 I/O 요청의 크기를 기반으로 통계 버킷(bucket)을 결정합니다.
이때 blk_mq_poll_stats_bkt() 함수가 호출됩니다:
// block/blk-mq.c
static int blk_mq_poll_stats_bkt(const struct request *rq)
{
int ddir, sectors, bucket;
ddir = rq_data_dir(rq);
sectors = blk_rq_stats_sectors(rq); // 원래 코드
if (!sectors)
return -1; // ← 항상 여기서 -1 반환!
...
}
blk_rq_stats_sectors(rq)는 단순히 rq->stats_sectors를 반환합니다.
이 값은 blk_mq_start_request()에서 QUEUE_FLAG_STATS 플래그가
설정된 경우에만 기록됩니다:
// block/blk-mq.c — blk_mq_start_request()
if (test_bit(QUEUE_FLAG_STATS, &q->queue_flags)) {
rq->io_start_time_ns = ktime_get_ns();
rq->stats_sectors = blk_rq_sectors(rq); // 여기서만 설정됨
}
이 플래그는 blk_stat_add_callback()을 호출하는 컴포넌트에 의해 활성화됩니다:
wbt_lat_usec 값이 0이 아니면 동작
DPAS 논문의 연구 환경에서는 I/O 스케줄러가 none이지만,
WBT가 활성화(wbt_lat_usec=2000)되어 있어
QUEUE_FLAG_STATS가 설정되고 blk_rq_stats_sectors()가 정상 동작합니다.
반면 QEMU 가상 NVMe 디바이스에서는 WBT가 지원되지 않아
(cat wbt_lat_usec → Invalid argument)
QUEUE_FLAG_STATS가 설정되지 않으며,
blk_rq_stats_sectors()는 항상 0을 반환합니다.
결과적으로 bucket = -1이 되어 DPAS 로직이 모두 스킵됩니다.
block/blk-wbt.c).
대량의 buffered write가 디바이스를 포화시키면 read 지연이 급증하는 문제(write starvation)를 방지하기 위해,
write I/O의 in-flight 개수를 동적으로 제한합니다.
blk_stat_add_callback()을 호출하여 QUEUE_FLAG_STATS를 활성화하고,
모든 요청에 대해 stats_sectors와 io_start_time_ns를 기록합니다.
wbt_lat_usec는 WBT의 목표 지연시간(마이크로초)입니다.
이 값이 0이 아니면 WBT가 활성 상태이며, 부수적으로 QUEUE_FLAG_STATS가 켜집니다.
NVMe SSD에서는 보통 기본값이 설정되어 있지만, QEMU 가상 디바이스에서는 WBT 자체가 지원되지 않습니다.
// block/blk-mq.c — blk_mq_poll_stats_bkt()
ddir = rq_data_dir(rq);
- sectors = blk_rq_stats_sectors(rq);
+ sectors = blk_rq_sectors(rq);
blk_rq_sectors(rq)는 QUEUE_FLAG_STATS와 무관하게
요청의 섹터 수를 항상 반환합니다.
blk_rq_stats_sectors는 QUEUE_FLAG_STATS(WBT, I/O 스케줄러 등에 의해 활성화)가
설정된 경우에만 유효하고, blk_rq_sectors는 항상 동작합니다.
동일한 코드가 환경에 따라 다르게 동작하는 이유를 이해하려면,
함수의 반환값이 어디서 설정되는지 역추적해야 합니다.
수정 1을 적용한 후에도 DPAS의 PAS→CP 모드 전환이 발생하지 않습니다.
switch_stat에서 MODE[2] (PAS)에 계속 머물러 있습니다.
NVMe 드라이버는 기본적으로 poll 전용 하드웨어 큐를 생성하지 않습니다 (poll_queues=0).
Poll 큐가 없으면 --hipri 플래그로 요청해도 interrupt 큐로 배정되어 polling이 불가능합니다.
# 수정: NVMe 모듈 로드 시 poll 큐 생성
sudo modprobe -r nvme
sudo modprobe nvme poll_queues=2
io_poll_delay의 기본값은 -1 (BLK_MQ_POLL_CLASSIC)입니다.
이 값에서는 blk_mq_poll()이 blk_mq_poll_hybrid()를 호출하지 않고
바로 classic busy-poll로 진입합니다.
// block/blk-mq.c — blk_mq_poll()
if (!(flags & BLK_POLL_NOSLEEP) &&
q->poll_nsec != BLK_MQ_POLL_CLASSIC) { // poll_nsec == -1이면 스킵
if (blk_mq_poll_hybrid(q, cookie)) // ← DPAS sleep 로직이 여기 있음
return 1;
}
DPAS의 핵심 로직 — QD 추적, 모드 전환 평가, adaptive sleep — 은
모두 blk_mq_poll_hybrid() → blk_mq_poll_pas_nsecs() 경로에 있습니다.
io_poll_delay=-1이면 이 경로가 완전히 우회됩니다.
# 수정: hybrid polling 활성화
echo 0 | sudo tee /sys/block/nvme0n1/queue/io_poll_delay
poll_queues ≥ 1)io_poll_delay ≥ 0)blk_mq_poll_pas_nsecs()가 호출됩니다.
수정 1, 2를 적용한 후, 단일 job(numjobs=1)에서는 DPAS 모드 전환이 정상 동작합니다.
그러나 multi-job(numjobs ≥ 2, 동일 CPU)에서 PAS 모드가 유지되어야 하는데
CP 모드로 잘못 전환됩니다.
DPAS의 hybrid polling sleep 루프를 살펴봅시다:
// block/blk-mq.c — blk_mq_poll_hybrid() 원래 코드
do {
set_current_state(TASK_UNINTERRUPTIBLE);
hrtimer_sleeper_start_expires(&hs, mode); // (1) 타이머 시작
if (hs.task) // (2) 만료 여부 확인
io_schedule(); // (3) CPU 양보
hrtimer_cancel(&hs.timer);
mode = HRTIMER_MODE_ABS;
} while (hs.task && !signal_pending(current));
정상 동작 시나리오 (실제 하드웨어, 충분한 타이머 해상도):
d_init=100ns 타이머를 설정hs.task != NULL (타이머 아직 안 만료)io_schedule() 호출 → CPU를 다른 job에 양보QEMU에서의 비정상 시나리오:
hs.task = NULL로 설정hs.task == NULL → io_schedule() 스킵
hrtimer_sleeper_start_expires()는 내부적으로
clockevents_program_event()을 호출합니다.
이 함수는 만료 시간을 설정하기 직전에 현재 시각을 다시 읽습니다:
// kernel/time/clockevents.c
delta = ktime_to_ns(ktime_sub(expires, ktime_get())); // 현재 시각 재확인
if (delta <= 0)
return -ETIME; // 이미 만료!
spinlock 획득, red-black tree 삽입, ktime_get() 호출 등의 과정에서
수백 ns ~ 수 μs가 소요됩니다.
QEMU의 타이머 에뮬레이션 오버헤드까지 더해지면,
100ns 타이머는 arm 과정에서 이미 만료될 수밖에 없습니다.
d_init 값별로 io_schedule() 호출 비율을 측정한 결과:
| d_init (ns) | io_schedule 미호출 | io_schedule 호출 | sleep 비율 | 판정 |
|---|---|---|---|---|
| 100 | 5,899 | 0 | 0% | sleep 전혀 안 됨 |
| 1,000 | 5,998 | 0 | 0% | sleep 전혀 안 됨 |
| 2,000 | 10,785 | 15 | 0.1% | 거의 안 됨 |
| 3,960 | 115 | 55,273 | 99.8% | 정상 sleep |
QEMU/KVM 환경에서 hrtimer의 실효 최소 해상도는 약 2,000~4,000ns입니다.
이보다 작은 값으로 타이머를 설정하면, arm 과정에서 만료되어 io_schedule()이 스킵됩니다.
if (hs.task) 체크를 제거하고 항상 io_schedule()을 호출합니다:
// block/blk-mq.c — blk_mq_poll_hybrid() 수정 코드
do {
set_current_state(TASK_UNINTERRUPTIBLE);
hrtimer_sleeper_start_expires(&hs, mode);
- if (hs.task)
- io_schedule();
+ io_schedule(); // 항상 CPU 양보
hrtimer_cancel(&hs.timer);
mode = HRTIMER_MODE_ABS;
} while (hs.task && !signal_pending(current));
타이머가 이미 만료된 경우, hrtimer_wakeup 콜백이 wake_up_process()를 호출하여
프로세스 상태를 TASK_RUNNING으로 변경합니다.
이 상태에서 io_schedule() → schedule()은
단순 yield (CPU 양보)로 동작하며, 즉시 복귀합니다.
schedule()이 yield로 동작 — CPU를 한 번 양보한 후 바로 복귀
이 수정은 blk_mq_poll_hybrid() 내부에만 적용되므로
polled I/O의 hybrid polling 경로에만 영향을 미치며,
커널의 나머지 부분에는 영향이 없습니다.
tf (timer floor) 카운터가 증가합니다.
기본 설정(param1=0)에서는 tf > 0이면 즉시 PAS→OL→INT 전환이 발생하여,
multi-job에서 DPAS가 사실상 interrupt 모드로 전락할 수 있습니다.
실제 하드웨어에서는 hrtimer 해상도가 충분하므로 이 수정이 불필요할 수 있으며,
적용 시 param1 임계값 조정이 함께 필요합니다.
모든 설정 오류가 있던 초기 상태에서의 결과입니다. PAS와 DPAS가 모두 classic polling(CP)과 동일한 성능을 보입니다 — hybrid polling 경로가 완전히 우회되었기 때문입니다.
| Mode | IOPS (j=1) | 비고 |
|---|---|---|
| INT | ~18,000 | interrupt 기반 |
| CP | ~22,000 | classic polling |
| PAS | ~22,000 | CP와 동일 (DPAS 미작동) |
| DPAS | ~27,000 | CP와 유사 (모드 전환 없음) |
세 가지 수정을 모두 적용한 후의 결과입니다. 각 모드의 성능 특성이 명확히 구분되며, DPAS 모드 전환이 정상 동작합니다.
| Mode | IOPS (j=1) | 비고 |
|---|---|---|
| INT | ~22,000 | interrupt 기반 (pvsync2, no hipri) |
| CP | ~37,000 | classic polling (io_poll_delay=-1) |
| PAS | ~31,000 | adaptive sleep + poll |
| DPAS | ~34,000 | CP:PAS = 10:1 전환 정상 동작 |
| jobs | INT | CP | PAS | DPAS |
|---|---|---|---|---|
| 1 | 21,879 | 36,633 | 30,628 | 34,250 |
| 2 | 42,950 | 33,955 | 49,494 | 42,565 |
| 4 | 61,739 | 32,908 | 44,379 | 67,800 |
| 8 | 83,633 | 38,007 | 50,194 | 74,615 |
| 16 | 84,183 | 37,433 | 57,368 | ~76,000 |
QUEUE_FLAG_STATS, poll_queues, io_poll_delay 등 코드가 실행되기 위한 조건을 하나씩 검증.switch_stat, io_poll_delay 등으로 런타임 상태를 확인.if 한 줄 제거. 커널 수정은 영향 범위를 최소화해야 합니다.같은 소스코드라도 실행 환경에 따라 동작이 달라질 수 있습니다. hrtimer의 경우, 실제 하드웨어에서는 100ns 타이머가 정상 동작하지만 QEMU에서는 arm 오버헤드로 인해 사실상 무효화됩니다. 이는 하드웨어 추상화(abstraction)의 한계를 보여주는 사례입니다: 커널 코드는 hrtimer API가 나노초 정밀도를 제공한다고 가정하지만, 가상화 환경에서는 이 가정이 성립하지 않습니다.
always-yield 수정은 QEMU 환경의 문제를 해결하지만, multi-job에서 새로운 부작용(PAS→OL→INT 전락)을 만듭니다. 이처럼 커널 수정은 한 문제의 해결이 다른 문제를 유발할 수 있으며, 수정의 영향을 전체 상태 머신 관점에서 분석해야 합니다.
| 수정 | 위치 | 변경량 | 효과 |
|---|---|---|---|
| blk_rq_stats_sectors → blk_rq_sectors | blk-mq.c | 1줄 | DPAS 상태 머신 진입 가능 |
| io_poll_delay=0, poll_queues=2 | sysfs 설정 | 런타임 | hybrid polling 경로 활성화 |
| if (hs.task) 제거 | blk-mq.c | 1줄 | QEMU에서 multi-job CPU 양보 보장 |
이 문서에 기술된 세 가지 수정은 AI 코딩 도구(Claude Code)를 활용하여 도출되었습니다. AI 도구는 커널 소스 코드를 분석하고, 증상으로부터 원인을 추론하며, 수정안을 제안하는 데 유용했습니다. 그러나 이 과정에서 AI의 설명이 정확하지 않았던 사례가 있으며, 이를 연구자가 식별하고 검증한 과정을 소개합니다.
| 항목 | AI의 판단 | 실제 |
|---|---|---|
| 문제 식별 | 정확 — blk_rq_stats_sectors()가 0을 반환하여 DPAS 로직이 스킵됨을 파악 |
맞음 |
| 수정안 | 정확 — blk_rq_sectors()로 교체 제안 |
맞음. 수정 후 DPAS 정상 동작 |
| 원인 설명 | 부정확 — "BFQ, kyber 같은 I/O 스케줄러가 플래그를 활성화하며,
QEMU의 none 스케줄러에서는 비활성"이라고 설명 |
논문 환경도 none 스케줄러 사용. 스케줄러가 원인이 아님 |
AI의 설명을 받은 연구자는 다음과 같은 의문을 제기했습니다:
none 스케줄러를 사용했는데,
스케줄러가 원인이라면 native에서도 동일한 문제가 발생했어야 하지 않는가?"
이 질문을 기점으로 논문 연구에 사용된 실제 서버에서 검증을 수행했습니다:
# 논문 환경 (native 서버) 검증
$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline ← none 스케줄러 확인
$ cat /sys/block/nvme0n1/queue/wbt_lat_usec
2000 ← WBT 활성 상태
# QEMU 환경 검증
$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline kyber ← 동일한 none 스케줄러
$ cat /sys/block/nvme0n1/queue/wbt_lat_usec
cat: Invalid argument ← WBT 미지원!
진짜 원인은 I/O 스케줄러가 아니라 WBT (Write Back Throttling)이었습니다.
Native 서버에서는 WBT가 활성화되어 QUEUE_FLAG_STATS를 설정하고 있었고,
QEMU 가상 NVMe에서는 WBT 자체가 지원되지 않아 해당 플래그가 비활성 상태였습니다.
blk_rq_stats_sectors → blk_rq_sectors 교체는 정확했고,
이 수정으로 DPAS가 실제로 동작하게 되었습니다.
커널 소스 수천 줄을 분석하여 원인 후보를 좁히는 작업에서 AI 도구는 효율적입니다.
QUEUE_FLAG_STATS가 스케줄러에 의해 활성화된다는 사실을 근거로
"QEMU는 none 스케줄러이므로 비활성"이라고 추론했습니다.
이 추론은 일부 맞지만, 핵심 요인(WBT)을 놓쳤습니다.
코드 분석은 맞았으나, 실제 환경의 런타임 상태까지 추론하지는 못했습니다.
none 스케줄러인데?"라는 질문은
해당 시스템의 실험 환경을 실제로 아는 연구자만이 제기할 수 있습니다.
AI 도구는 코드를 읽을 수 있지만, 특정 서버의 런타임 설정을 알 수는 없습니다.
수정의 정확성(correctness)과 설명의 정확성(explanation)은 별개이며,
둘 다 검증해야 합니다.