sched_ext — BPF 확장 스케줄러(Scheduler)
Linux 커널 6.12에 정식 머지된 sched_ext 프레임워크를 심층 분석합니다. BPF struct_ops를 통해 스케줄링 정책을 커널 재컴파일 없이 교체할 수 있는 혁신적인 아키텍처를 살펴봅니다. DSQ(Dispatch Queue) 계층, sched_ext_ops 콜백(Callback) 체인, 태스크(Task) 생명주기, CPU 선택 메커니즘, 에러 복구를 추적하고, scx_rusty, scx_lavd, scx_rustland 등 실전 스케줄러 구현과 나만의 BPF 스케줄러 작성 가이드를 포괄합니다.
sched_ext는 게이트 배정 규칙만 담긴 교체 가능한 플러그인 카드를 슬롯에 꽂는 것과 같습니다. 잘못된 카드를 꽂으면 시스템이 자동으로 기본 규칙(CFS)으로 되돌아갑니다.
핵심 요약
- BPF 기반 스케줄링 —
struct_ops를 통해 스케줄링 정책을 BPF 프로그램으로 정의하고, 커널 재컴파일 없이 동적으로 로드/언로드합니다. - DSQ 계층 — Local DSQ(per-CPU), Global DSQ, 사용자 정의 DSQ의 3계층 디스패치(Dispatch) 큐로 태스크를 관리합니다.
- 안전한 폴백 — BPF 스케줄러에 버그가 있거나 watchdog 타임아웃이 발생하면 자동으로 CFS/EEVDF로 복구됩니다.
- Meta/Google 주도 — Meta의 Tejun Heo와 David Vernet이 주도하고, Google도 적극 참여하여 v6.12에 머지되었습니다.
- 실전 검증 — scx_rusty(NUMA-aware), scx_lavd(지연(Latency) 최적화), scx_rustland(유저스페이스 스케줄링) 등이 프로덕션에서 활용됩니다.
- Rust 생태계 — scx 저장소의 대부분의 스케줄러가 Rust로 작성되어 안전성과 생산성을 동시에 확보합니다.
- 빠른 반복 — 스케줄러를 수초 내에 교체할 수 있어, 워크로드별 최적 정책을 빠르게 실험할 수 있습니다.
단계별 이해
- 1단계: 배경 이해 — 기존
sched_class계층(stop → dl → rt → fair → idle)에서 ext가 어디에 위치하는지 파악합니다. - 2단계: 아키텍처 — 커널 코어(
kernel/sched/ext.c), BPF 프로그램(.bpf.c), 유저스페이스 로더(Loader) 3계층의 역할을 이해합니다. - 3단계: DSQ 메커니즘 — 태스크가 enqueue → DSQ 삽입 → dispatch → running으로 흐르는 과정을 추적합니다.
- 4단계: ops 콜백 —
sched_ext_ops의 각 콜백(select_cpu, enqueue, dispatch, running, stopping 등)이 언제 호출되는지 학습합니다. - 5단계: 실습 — scx_simple부터 시작하여 나만의 스케줄러를 작성하고 벤치마크합니다.
sched_ext 개발 역사
sched_ext는 Linux 커널 스케줄러의 확장성 한계를 극복하기 위해 탄생했습니다. 전통적으로 스케줄링 정책을 변경하려면 커널 소스를 수정하고 재컴파일해야 했으며, 이는 프로덕션 환경에서 큰 부담이었습니다.
| 시기 | 이벤트 | 의미 |
|---|---|---|
| 2022.11 | Tejun Heo(Meta) RFC v1 패치(Patch)셋 게시 | BPF struct_ops 기반 스케줄러 확장 개념 최초 제안 |
| 2023.06 | RFC v2 — DSQ 아키텍처 도입 | Dispatch Queue 계층으로 태스크 흐름 체계화 |
| 2023.11 | v3 — scx_rusty, scx_lavd 공개 | Rust 기반 실전 스케줄러로 실용성 검증 |
| 2024.01 | v4 — watchdog 및 에러 처리 강화 | 안전한 폴백 메커니즘 완성 |
| 2024.05 | v5 — cgroup 지원 추가 | 컨테이너(Container) 환경 대응 |
| 2024.06 | v6 — Google 협업, 성능 최적화 | 대규모 데이터센터 워크로드 검증 |
| 2024.09 | Linux 6.12-rc1 — 공식 머지 | Linus 승인, mainline 진입 |
| 2024.11 | Linux 6.12 정식 릴리스 | 프로덕션 사용 가능 |
sched_ext의 개발 과정에서 가장 큰 기술적 도전은 안전성이었습니다. 스케줄러는 시스템의 심장부이므로, BPF 프로그램의 버그가 시스템 전체를 멈출 수 있습니다. 이를 해결하기 위해 watchdog 타이머(Timer), 자동 CFS 폴백, ops.exit() 정리 메커니즘이 설계되었습니다.
/* kernel/sched/ext.c — sched_ext 핵심 초기화 */
static struct sched_class ext_sched_class;
/* ext 클래스는 fair 아래, idle 위에 위치 */
/* 우선순위: stop > dl > rt > fair > ext > idle */
DEFINE_SCHED_CLASS(ext) = {
.enqueue_task = enqueue_task_scx,
.dequeue_task = dequeue_task_scx,
.pick_next_task = pick_next_task_scx,
.put_prev_task = put_prev_task_scx,
.set_next_task = set_next_task_scx,
.task_tick = task_tick_scx,
.select_task_rq = select_task_rq_scx,
.check_preempt_curr = check_preempt_curr_scx,
};
기존 sched_class와의 관계
Linux 커널 스케줄러는 sched_class의 연결 리스트(Linked List)로 우선순위(Priority)를 정의합니다. 각 클래스는 pick_next_task를 구현하며, 가장 높은 우선순위 클래스부터 실행 가능한 태스크를 탐색합니다.
| 우선순위 | sched_class | 정책 | 용도 |
|---|---|---|---|
| 1 (최고) | stop_sched_class | - | CPU 핫플러그(Hotplug), 마이그레이션 |
| 2 | dl_sched_class | SCHED_DEADLINE | EDF 기반 실시간 |
| 3 | rt_sched_class | SCHED_FIFO/RR | 고정 우선순위 실시간 |
| 4 | fair_sched_class | SCHED_NORMAL/BATCH | CFS/EEVDF |
| 5 | ext_sched_class | SCHED_EXT | BPF 확장 스케줄러 |
| 6 (최저) | idle_sched_class | SCHED_IDLE | idle 태스크 |
sched_setscheduler()로 명시적으로 SCHED_EXT 정책을 설정하거나, BPF 스케줄러가 SCX_OPS_ENQ_LAST 플래그를 사용하여 모든 태스크를 가져와야 합니다. 실제로 대부분의 scx 스케줄러는 SCX_OPS_ENQ_LAST를 사용하여 CFS 태스크를 포함한 모든 일반 태스크를 관리합니다.
/* include/linux/sched/ext.h — sched_class 연결 */
/*
* 클래스 탐색 순서 (pick_next_task):
* stop → dl → rt → fair → ext → idle
*
* ext가 활성화되면 SCHED_NORMAL 태스크도 ext로 이동 가능
* SCX_OPS_ENQ_LAST 플래그로 fair에서 남은 태스크를 ext가 흡수
*/
#define SCX_OPS_ENQ_LAST (1U << 0) /* fair 미처리 태스크를 ext가 수용 */
#define SCX_OPS_ENQ_EXITING (1U << 1) /* exit 중인 태스크도 enqueue */
#define SCX_OPS_SWITCH_PARTIAL (1U << 2) /* 일부 태스크만 ext로 전환 */
sched_ext 전체 아키텍처
sched_ext는 크게 3개의 계층으로 구성됩니다. 커널 코어가 스케줄링 프레임워크를 제공하고, BPF 프로그램이 스케줄링 정책을 정의하며, 유저스페이스 로더가 BPF 프로그램을 관리합니다.
| 계층 | 구성요소 | 역할 |
|---|---|---|
| 유저스페이스 | scx 로더, sysfs 인터페이스 | BPF 스케줄러 로드/언로드, 모니터링 |
| BPF 프로그램 | SEC("struct_ops") 함수들 | 스케줄링 정책 구현 (enqueue, dispatch 등) |
| 커널 코어 | kernel/sched/ext.c | DSQ 관리, ops 콜백 호출, watchdog, 폴백 |
/* kernel/sched/ext.c — 핵심 자료구조 */
struct sched_ext_entity {
struct scx_dispatch_q *dsq; /* 현재 소속 DSQ */
struct list_head dsq_list; /* DSQ 내 연결 리스트 */
u64 dsq_vtime; /* 가상 시간 (WFQ용) */
u64 slice; /* 남은 타임 슬라이스 */
u32 flags; /* SCX_TASK_* 플래그 */
u32 weight; /* 태스크 가중치 */
s32 sticky_cpu; /* CPU 고정 힌트 */
u32 kf_mask; /* 허용된 kfunc 마스크 */
atomic_long_t ops_state; /* 태스크 상태 머신 */
};
struct scx_dispatch_q {
raw_spinlock_t lock;
struct list_head list; /* FIFO 태스크 리스트 */
struct rb_root priq; /* vtime 기반 우선순위 큐 */
u32 nr; /* 큐 내 태스크 수 */
u64 id; /* DSQ ID */
u64 vtime_now; /* 현재 가상 시간 */
};
kernel/sched/ext.c, kernel/sched/ext.h, include/linux/sched/ext.h에 위치합니다. BPF 헬퍼와 kfunc는 kernel/sched/ext.c 하단에 정의되어 있습니다.
DSQ(Dispatch Queue) 계층
DSQ는 sched_ext의 핵심 추상화입니다. 태스크는 BPF 스케줄러에 의해 DSQ에 삽입되고, 커널 코어가 DSQ에서 태스크를 꺼내 CPU에 디스패치합니다. 3종류의 DSQ가 있습니다:
| DSQ 종류 | ID | 범위 | 용도 |
|---|---|---|---|
| Local DSQ | SCX_DSQ_LOCAL | per-CPU | 특정 CPU에서 즉시 실행할 태스크 |
| Global DSQ | SCX_DSQ_GLOBAL | 시스템 전체 | 아무 CPU에서 실행 가능한 태스크 |
| Custom DSQ | 사용자 정의 u64 | 사용자 정의 | NUMA 노드별, cgroup별 등 커스텀 그룹 |
/* DSQ 기본 상수 */
#define SCX_DSQ_LOCAL 0 /* 현재 CPU의 Local DSQ */
#define SCX_DSQ_GLOBAL 1 /* 시스템 전체 Global DSQ */
#define SCX_DSQ_LOCAL_ON 2 /* 특정 CPU의 Local DSQ */
#define SCX_DSQ_INVALID UINT64_MAX /* 무효 DSQ */
/* Custom DSQ 생성 및 사용 */
static int create_dsq(void)
{
s32 ret;
/* NUMA 노드 0 전용 DSQ 생성 */
ret = scx_bpf_create_dsq(100, 0); /* id=100, NUMA node=0 */
if (ret)
return ret;
/* NUMA 노드 1 전용 DSQ 생성 */
ret = scx_bpf_create_dsq(101, 1); /* id=101, NUMA node=1 */
return ret;
}
scx_bpf_dsq_insert()은 FIFO 순서로 삽입하고, scx_bpf_dsq_insert_vtime()은 가상 시간 기반 우선순위 큐에 삽입합니다. vtime 모드는 WFQ(Weighted Fair Queueing) 구현에 사용됩니다.
DSQ 내부 데이터 구조 상세
DSQ(Dispatch Queue)는 내부적으로 FIFO 리스트(list_head)와 RB-tree(rb_root_cached)의 이중 구조를 사용합니다. scx_bpf_dsq_insert()은 FIFO 리스트 꼬리에 태스크를 추가하고, scx_bpf_dsq_insert_vtime()은 vtime을 키로 RB-tree에 삽입합니다. 디스패치 시에는 FIFO 리스트가 항상 우선 소비되며, FIFO가 비어야 RB-tree에서 가장 작은 vtime의 태스크를 꺼냅니다.
/* kernel/sched/ext.c — DSQ 내부 구조체 (핵심 필드) */
struct scx_dispatch_q {
raw_spinlock_t lock; /* DSQ 접근 보호 */
struct list_head list; /* FIFO 리스트 (scx_bpf_dsq_insert) */
struct rb_root_cached priq; /* RB-tree (scx_bpf_dsq_insert_vtime) */
u32 nr; /* 총 태스크 수 (FIFO + RB) */
u64 id; /* DSQ ID */
struct rhashtable_head hash_node; /* ID 기반 해시 룩업용 */
u64 vtime_now; /* 현재 가상 시간 워터마크 */
struct list_head all_node; /* 전체 DSQ 리스트 연결 */
};
/* per-task DSQ 연결 정보 (task_struct→scx 내부) */
struct sched_ext_entity {
struct scx_dispatch_q *dsq; /* 현재 속한 DSQ */
struct list_head dsq_list; /* FIFO 리스트 노드 */
struct rb_node dsq_priq; /* RB-tree 노드 */
u64 dsq_vtime; /* 삽입된 vtime 값 */
u64 dsq_flags; /* DSQ_FIFO / DSQ_PRIQ 플래그 */
u32 flags; /* SCX_TASK_* 상태 플래그 */
u64 slice; /* 잔여 타임 슬라이스 */
};
코드 설명
kernel/sched/ext.c에 정의된 DSQ 관련 핵심 자료구조 두 가지입니다.
- scx_dispatch_qDSQ 자체를 표현하는 구조체입니다. FIFO 삽입용
list(연결 리스트)와 vtime 기반 삽입용priq(RB-tree)를 동시에 유지합니다.raw_spinlock_t lock으로 동시 접근을 보호합니다. - vtime_now현재 가상 시간 워터마크입니다. vtime 기반 삽입 시 시간 역행을 방지하고, 태스크 소비 시 갱신되어 공정한 가상 시간 진행을 보장합니다.
- hash_nodeDSQ ID로 빠른 해시 룩업을 가능하게 합니다.
scx_bpf_consume(dsq_id)호출 시 이 해시를 통해 O(1)에 DSQ를 찾습니다. - sched_ext_entity
task_struct→scx내부에 포함된 per-task DSQ 연결 정보입니다.dsq_list와dsq_priq로 각각 FIFO/RB-tree에 연결되며,slice는 남은 CPU 시간입니다.
/* FIFO 삽입 경로: scx_bpf_dsq_insert() */
static void dsq_insert_fifo(struct scx_dispatch_q *dsq,
struct task_struct *p,
u64 enq_flags)
{
unsigned long flags;
raw_spin_lock_irqsave(&dsq->lock, flags);
/* FIFO 리스트 꼬리에 삽입 */
p->scx.dsq_flags = SCX_DSQ_FIFO;
list_add_tail(&p->scx.dsq_list, &dsq->list);
p->scx.dsq = dsq;
dsq->nr++;
raw_spin_unlock_irqrestore(&dsq->lock, flags);
}
/* vtime 삽입 경로: scx_bpf_dsq_insert_vtime() */
static void dsq_insert_vtime(struct scx_dispatch_q *dsq,
struct task_struct *p,
u64 vtime, u64 enq_flags)
{
unsigned long flags;
struct rb_node **link, *parent = NULL;
bool leftmost = true;
raw_spin_lock_irqsave(&dsq->lock, flags);
/* vtime_now 워터마크 갱신 — 시간 역행 방지 */
if (vtime_before(vtime, dsq->vtime_now))
vtime = dsq->vtime_now;
p->scx.dsq_vtime = vtime;
p->scx.dsq_flags = SCX_DSQ_PRIQ;
/* RB-tree에 vtime 순서로 삽입 */
link = &dsq->priq.rb_root.rb_node;
while (*link) {
struct task_struct *curr;
parent = *link;
curr = rb_entry(parent, struct task_struct,
scx.dsq_priq);
if (vtime_before(vtime, curr->scx.dsq_vtime)) {
link = &parent->rb_left;
} else {
link = &parent->rb_right;
leftmost = false;
}
}
rb_link_node(&p->scx.dsq_priq, parent, link);
rb_insert_color_cached(&p->scx.dsq_priq,
&dsq->priq, leftmost);
p->scx.dsq = dsq;
dsq->nr++;
raw_spin_unlock_irqrestore(&dsq->lock, flags);
}
/* DSQ에서 태스크 꺼내기 — FIFO 우선, 이후 RB-tree */
static struct task_struct *dsq_pop(struct scx_dispatch_q *dsq)
{
struct task_struct *p;
lockdep_assert_held(&dsq->lock);
/* 1) FIFO 리스트 우선 확인 */
if (!list_empty(&dsq->list)) {
p = list_first_entry(&dsq->list,
struct task_struct,
scx.dsq_list);
list_del_init(&p->scx.dsq_list);
goto found;
}
/* 2) RB-tree의 rb_leftmost (최소 vtime) — O(1) */
if (!RB_EMPTY_ROOT(&dsq->priq.rb_root)) {
struct rb_node *left;
left = dsq->priq.rb_leftmost;
p = rb_entry(left, struct task_struct,
scx.dsq_priq);
rb_erase_cached(&p->scx.dsq_priq, &dsq->priq);
/* vtime_now를 소비한 태스크의 vtime으로 갱신 */
dsq->vtime_now = p->scx.dsq_vtime;
goto found;
}
return NULL;
found:
p->scx.dsq = NULL;
dsq->nr--;
return p;
}
/* vtime_now 추적 메커니즘 상세 */
/*
* vtime_before() — wrapping-safe 가상 시간 비교
*
* vtime은 u64이지만, 커널 전체 수명에서 오버플로우가 발생할 수 있으므로
* signed 비교를 통해 wrap-around를 안전하게 처리합니다.
* 이는 jiffies 비교의 time_before()와 동일한 기법입니다.
*/
static inline bool vtime_before(u64 a, u64 b)
{
return (s64)(a - b) < 0;
}
/*
* vtime_now 갱신 규칙:
*
* 1. 삽입 시: vtime이 vtime_now보다 과거면 vtime_now로 보정
* → 장기 sleep 후 깨어난 태스크가 과거 vtime으로
* 무한히 우선 실행되는 것을 방지
*
* 2. 소비 시: 꺼낸 태스크의 vtime을 vtime_now에 반영
* → 시간이 단조 증가하도록 보장
*
* 3. BPF 스케줄러의 vtime 계산:
* stopping 콜백에서 실행 시간을 weight로 나눈 값을 누적
* → WFQ(Weighted Fair Queueing) 구현
*/
/* WFQ를 위한 vtime 갱신 — stopping 콜백에서 호출 */
SEC("struct_ops/stopping")
void BPF_PROG(wfq_stopping, struct task_struct *p, bool runnable)
{
struct task_ctx *tctx;
u64 delta, weighted_delta;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (!tctx)
return;
/* 실제 실행 시간 (ns) */
delta = bpf_ktime_get_ns() - tctx->last_running;
/* weight가 높은 태스크는 vtime이 느리게 증가 → 더 많이 실행됨
* weight가 낮은 태스크는 vtime이 빠르게 증가 → 적게 실행됨
*
* 예: weight 200 태스크가 1ms 실행 → vtime += 0.5ms
* weight 50 태스크가 1ms 실행 → vtime += 2ms
*/
weighted_delta = delta * 100 / tctx->weight;
tctx->vtime += weighted_delta;
}
dsq->lock이 심각한 경합 지점(Contention Point)이 될 수 있습니다. 이를 완화하는 전략은 다음과 같습니다:
- per-NUMA DSQ 분할: NUMA 노드마다 별도 DSQ를 생성하여 잠금 범위를 축소 (scx_rusty 방식)
- per-LLC DSQ 분할: LLC(Last-Level Cache) 도메인별 DSQ로 더 세밀하게 분할
- Local DSQ 직접 삽입:
select_cpu에서 idle CPU를 찾아SCX_DSQ_LOCAL로 직접 삽입하면 Global DSQ를 우회 - 배치(Batch) 삽입: BPF
dispatch콜백에서 여러 태스크를 한 번에 이동하여 잠금 횟수를 줄임
sched_ext_ops 콜백 상세
sched_ext_ops는 BPF 스케줄러가 구현해야 하는 콜백 함수 집합입니다. 모든 콜백은 선택적이며, 구현하지 않으면 커널이 기본 동작을 수행합니다.
| 콜백 | 호출 시점 | 필수 여부 | 주요 작업 |
|---|---|---|---|
select_cpu | 태스크 깨어남 | 선택 | 실행할 CPU 선택, idle CPU 최적화 |
enqueue | 태스크가 runnable | 선택 | DSQ에 태스크 삽입 |
dequeue | 태스크가 !runnable | 선택 | DSQ에서 태스크 제거 |
dispatch | Local DSQ 비었을 때 | 선택 | Custom/Global DSQ → Local DSQ 이동 |
running | CPU에서 실행 시작 | 선택 | 통계 갱신, 타임스탬프 기록 |
stopping | CPU에서 실행 중단 | 선택 | 실행 시간 계산, vtime 갱신 |
runnable | 태스크 깨어남 | 선택 | 상태 전이 추적 |
quiescent | 태스크 잠듬 | 선택 | 상태 전이 추적 |
tick | 스케줄러 틱 | 선택 | 타임 슬라이스 만료 확인 |
init_task | 태스크 생성 | 선택 | per-task 데이터 초기화 |
exit_task | 태스크 종료 | 선택 | per-task 데이터 정리 |
init | 스케줄러 로드 | 선택 | 전역 초기화, DSQ 생성 |
exit | 스케줄러 언로드 | 선택 | 정리, 종료 사유 로깅 |
cpu_online | CPU 핫플러그 온라인 | 선택 | CPU 추가 시 재설정 |
cpu_offline | CPU 핫플러그 오프라인 | 선택 | CPU 제거 시 태스크 이동 |
/* include/linux/sched/ext.h — sched_ext_ops 구조체 (핵심 필드) */
struct sched_ext_ops {
/* 태스크 enqueue 콜백 — 태스크가 runnable 될 때 호출 */
void (*enqueue)(struct task_struct *p, u64 enq_flags);
/* 태스크 dequeue 콜백 — 태스크가 sleep/exit 할 때 호출 */
void (*dequeue)(struct task_struct *p, u64 deq_flags);
/* dispatch 콜백 — Local DSQ가 비었을 때 호출 */
void (*dispatch)(s32 cpu, struct task_struct *prev);
/* CPU 선택 콜백 — wakeup 시 실행 CPU 결정 */
s32 (*select_cpu)(struct task_struct *p, s32 prev_cpu, u64 wake_flags);
/* 태스크가 CPU에서 실행 시작 */
void (*running)(struct task_struct *p);
/* 태스크가 CPU에서 실행 중단 */
void (*stopping)(struct task_struct *p, bool runnable);
/* 스케줄러 틱 */
void (*tick)(struct task_struct *p);
/* 이름, 플래그, 타임아웃 */
char name[SCX_OPS_NAME_LEN];
u64 flags;
u32 timeout_ms; /* watchdog 타임아웃 (기본 30초) */
};
코드 설명
include/linux/sched/ext.h에 정의된 sched_ext_ops 구조체는 BPF 스케줄러가 커널에 등록하는 콜백 함수 테이블입니다.
- enqueue태스크가 실행 가능(runnable) 상태가 되면 호출됩니다. BPF 스케줄러는 여기서
scx_bpf_dsq_insert()를 호출하여 태스크를 적절한 DSQ에 배치합니다. - dequeue태스크가 sleep 또는 exit 할 때 호출됩니다. DSQ에서 태스크를 제거하는 정리 작업을 수행합니다.
- dispatch현재 CPU의 Local DSQ가 비었을 때 호출됩니다.
scx_bpf_consume()으로 Custom/Global DSQ에서 태스크를 가져옵니다. - select_cpu태스크 wakeup 시 가장 먼저 호출되어 실행할 CPU를 결정합니다. idle CPU를 찾으면 Local DSQ에 직접 삽입하여 enqueue를 우회할 수 있습니다.
- running / stopping태스크가 CPU에서 실행을 시작/중단할 때 호출됩니다. per-task 통계 수집이나 vtime 갱신에 사용됩니다.
- timeout_mswatchdog 타임아웃(기본 30초)입니다. 이 시간 내에 태스크가 디스패치되지 않으면
SCX_EXIT_ERROR_STALL로 CFS 폴백이 발생합니다.
enqueue 콜백을 구현하지 않으면, 태스크는 자동으로 Global DSQ에 삽입됩니다. 이는 간단하지만 CPU 지역성이 없어 성능이 떨어집니다. 실전 스케줄러는 반드시 enqueue를 구현하여 적절한 DSQ에 태스크를 배치해야 합니다.
태스크 생명주기
sched_ext에서 관리되는 태스크는 명확한 상태 전이를 거칩니다. 각 전이 시점에 대응하는 ops 콜백이 호출됩니다.
/* 태스크 상태 플래그 */
#define SCX_TASK_QUEUED (1U << 0) /* DSQ에 삽입됨 */
#define SCX_TASK_DEQD_FOR_SLEEP (1U << 1) /* sleep을 위해 dequeue */
#define SCX_TASK_STATE_SHIFT 8
#define SCX_TASK_STATE_BITS 2
#define SCX_TASK_STATE_MASK ((((1U << SCX_TASK_STATE_BITS) - 1) << SCX_TASK_STATE_SHIFT))
enum scx_task_state {
SCX_TASK_NONE, /* 초기 상태 */
SCX_TASK_INIT, /* init_task() 완료 */
SCX_TASK_READY, /* enable() 완료, 스케줄링 가능 */
SCX_TASK_ENABLED, /* 활성 상태 */
};
코드 설명
include/linux/sched/ext.h에 정의된 태스크 상태 플래그와 상태 열거형입니다. task_struct→scx.flags에 저장됩니다.
- SCX_TASK_QUEUED태스크가 어떤 DSQ에 삽입되어 있음을 나타냅니다.
enqueue_task_scx()에서 설정되고, 태스크가 CPU에서 실행을 시작하면 해제됩니다. - SCX_TASK_DEQD_FOR_SLEEP태스크가 sleep을 위해 dequeue되었음을 표시합니다. 이 플래그가 설정된 태스크는
ops.quiescent콜백을 트리거합니다. - SCX_TASK_STATE_MASK비트 8~9에 저장되는 상태 값의 마스크입니다. 상위 비트를 사용하여 플래그 비트와 충돌하지 않게 설계되었습니다.
- scx_task_state 열거형
NONE→INIT(init_task 완료) →READY(enable 완료) →ENABLED(활성) 순서로 전이됩니다. 각 전이 시점에 대응하는 ops 콜백(init_task,enable)이 호출됩니다.
CPU 선택 메커니즘
태스크가 깨어날 때 가장 먼저 호출되는 콜백이 select_cpu입니다. 이 콜백은 태스크를 실행할 CPU를 결정하고, 선택적으로 해당 CPU의 Local DSQ에 직접 삽입할 수 있습니다.
/* BPF 스케줄러에서 select_cpu 구현 예시 */
SEC("struct_ops/select_cpu")
s32 BPF_PROG(my_select_cpu, struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
bool is_idle = false;
s32 cpu;
/* 기본 idle CPU 선택 알고리즘 사용 */
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
if (is_idle) {
/* idle CPU 발견 — Local DSQ에 직접 삽입 (enqueue 스킵) */
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL,
0);
return cpu;
}
/* idle CPU 없음 — prev_cpu 반환 후 enqueue에서 처리 */
return prev_cpu;
}
코드 설명
BPF 스케줄러에서 ops.select_cpu 콜백을 구현하는 예시입니다. kernel/sched/ext.c의 enqueue_task_scx()가 wakeup 시 이 콜백을 가장 먼저 호출합니다.
- scx_bpf_select_cpu_dfl()커널이 제공하는 기본 idle CPU 선택 알고리즘을 호출합니다. SMT 시블링(Sibling), LLC, NUMA 노드 순서로 idle CPU를 탐색하며, 결과를
is_idle플래그에 기록합니다. - idle CPU 발견 시
scx_bpf_dsq_insert()로 해당 CPU의 Local DSQ(SCX_DSQ_LOCAL)에 직접 삽입합니다. 이렇게 하면enqueue→dispatch경로를 우회하여 wakeup 레이턴시를 크게 줄일 수 있습니다. - idle CPU 없음 시
prev_cpu를 반환하여 이전 CPU에서 후속enqueue콜백이 호출되도록 합니다. enqueue에서 적절한 DSQ에 태스크를 배치합니다.
select_cpu에서 idle CPU를 찾아 Local DSQ에 직접 삽입하면, enqueue → dispatch 경로를 우회하여 레이턴시를 크게 줄일 수 있습니다. 대부분의 실전 스케줄러(scx_rusty, scx_lavd)는 이 패턴을 사용합니다.
로드 밸런싱
sched_ext에서 로드 밸런싱은 dispatch 콜백과 scx_bpf_consume() 헬퍼를 통해 구현됩니다. Local DSQ가 비었을 때 커널이 dispatch를 호출하면, BPF 스케줄러가 Custom/Global DSQ에서 태스크를 가져옵니다.
/* dispatch 콜백 구현 예시 */
SEC("struct_ops/dispatch")
void BPF_PROG(my_dispatch, s32 cpu, struct task_struct *prev)
{
u64 dsq_id;
/* 현재 CPU의 NUMA 노드에 해당하는 Custom DSQ에서 소비 */
dsq_id = cpu_to_node(cpu);
scx_bpf_consume(dsq_id);
/* Custom DSQ가 비었으면 Global DSQ에서 소비 */
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
/* enqueue에서 적절한 DSQ에 삽입 */
SEC("struct_ops/enqueue")
void BPF_PROG(my_enqueue, struct task_struct *p, u64 enq_flags)
{
u64 dsq_id;
/* 태스크의 현재 CPU 기반으로 NUMA 노드 DSQ 선택 */
dsq_id = cpu_to_node(scx_bpf_task_cpu(p));
scx_bpf_dsq_insert(p, dsq_id, SCX_SLICE_DFL, 0);
}
코드 설명
NUMA 노드 기반 로드 밸런싱을 구현하는 dispatch와 enqueue 콜백 쌍입니다. scx_bpf_dsq_insert()와 scx_bpf_consume() 헬퍼가 핵심 역할을 합니다.
- dispatch 콜백Local DSQ가 비었을 때 커널이 호출합니다. 먼저 현재 CPU의 NUMA 노드에 해당하는 Custom DSQ에서
scx_bpf_consume()으로 태스크를 가져오고, 없으면SCX_DSQ_GLOBAL에서 가져옵니다. - scx_bpf_consume()
kernel/sched/ext.c에 정의된 kfunc로, 지정된 DSQ에서 태스크를 꺼내 현재 CPU의 Local DSQ로 이동시킵니다. dispatch 콜백에서만 호출할 수 있습니다. - enqueue 콜백태스크의 현재 CPU 기반으로 NUMA 노드 ID를 DSQ ID로 사용하여
scx_bpf_dsq_insert()로 해당 Custom DSQ에 삽입합니다.SCX_SLICE_DFL(20ms)을 타임 슬라이스로 부여합니다.
Tick과 시간 슬라이스
sched_ext는 태스크에 타임 슬라이스(slice)를 부여하여 CPU 시간을 제어합니다. 매 스케줄러 틱마다 ops.tick이 호출되고, 슬라이스가 만료되면 태스크가 선점(Preemption)됩니다.
| 상수 | 값 | 의미 |
|---|---|---|
SCX_SLICE_DFL | 20ms | 기본 타임 슬라이스 |
SCX_SLICE_INF | U64_MAX | 무한 슬라이스 (선점 없음) |
/* tick 콜백에서 커스텀 시간 슬라이스 관리 */
SEC("struct_ops/tick")
void BPF_PROG(my_tick, struct task_struct *p)
{
/* 대화형 태스크에 짧은 슬라이스 */
if (is_interactive(p))
p->scx.slice = 5 * NSEC_PER_MSEC;
else
p->scx.slice = 20 * NSEC_PER_MSEC;
}
/* enqueue 시 슬라이스 설정 */
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
10 * NSEC_PER_MSEC, /* 10ms 슬라이스 */
0); /* enq_flags */
선점(Preemption)과 우선순위 제어
sched_ext에서의 선점(Preemption)은 전통적 CFS/EEVDF와 다르게 BPF 스케줄러가 직접 제어할 수 있습니다. 기본적으로 타임 슬라이스(slice) 만료에 의한 자연 선점이 발생하지만, scx_bpf_kick_cpu()와 SCX_KICK_PREEMPT 플래그를 통해 즉시 선점을 강제할 수도 있습니다.
| 선점 메커니즘 | 트리거 | 동작 | 사용 사례 |
|---|---|---|---|
| 슬라이스 만료 | p->scx.slice == 0 | tick에서 태스크 교체 | 기본 선점 (모든 스케줄러) |
| SCX_KICK_PREEMPT | scx_bpf_kick_cpu(cpu, SCX_KICK_PREEMPT) | IPI로 즉시 스케줄링 포인트 강제 | 우선순위 높은 태스크 도착 |
| SCX_KICK_IDLE | scx_bpf_kick_cpu(cpu, SCX_KICK_IDLE) | idle CPU만 깨움 | 유휴 CPU 활성화 |
| SCX_ENQ_PREEMPT | scx_bpf_dsq_insert(..., SCX_ENQ_PREEMPT) | DSQ 삽입 시 현재 태스크 선점 요청 | 긴급 태스크 삽입 |
| 자발적 양보 | sched_yield() / 블록 | 태스크가 CPU 반환 | I/O 대기, 뮤텍스(Mutex) 대기 |
대화형 태스크 감지와 우선순위 부스트(Priority Boost)는 sched_ext 스케줄러의 핵심 기능 중 하나입니다. 대화형 태스크는 짧은 CPU 버스트(burst) 후 자발적으로 블록되는 패턴을 보이며, 이를 감지하여 짧은 슬라이스와 즉시 선점을 부여합니다.
/* 대화형 태스크 감지 및 선점 제어 구현 */
struct task_ctx {
u64 avg_runtime_ns; /* EWMA 평균 실행 시간 */
u64 avg_sleep_ns; /* EWMA 평균 수면 시간 */
u32 voluntary_switches; /* 자발적 컨텍스트 전환 횟수 */
u32 involuntary_switches; /* 비자발적 컨텍스트 전환 횟수 */
bool is_interactive; /* 대화형 태스크 여부 */
u64 last_wake_at; /* 마지막 깨어난 시각 */
u64 last_run_at; /* 마지막 실행 시작 시각 */
};
#define INTERACTIVE_RUNTIME_THRESH (5 * NSEC_PER_MSEC)
#define INTERACTIVE_SLEEP_RATIO 4 /* sleep/run > 4이면 대화형 */
#define EWMA_WEIGHT 4 /* EWMA 가중치 (1/4) */
/* EWMA 업데이트 헬퍼 */
static u64 ewma_update(u64 old, u64 new)
{
return (old * (EWMA_WEIGHT - 1) + new) / EWMA_WEIGHT;
}
/* 대화형 태스크 판별 */
static bool detect_interactive(struct task_ctx *tctx)
{
/* 조건 1: 평균 실행 시간이 임계값 이하 */
if (tctx->avg_runtime_ns > INTERACTIVE_RUNTIME_THRESH)
return false;
/* 조건 2: 수면/실행 비율이 충분히 높음 */
if (tctx->avg_runtime_ns == 0)
return false;
if (tctx->avg_sleep_ns / tctx->avg_runtime_ns < INTERACTIVE_SLEEP_RATIO)
return false;
/* 조건 3: 자발적 전환이 비자발적보다 많음 */
return tctx->voluntary_switches > tctx->involuntary_switches * 2;
}
/* enqueue 콜백: 대화형 태스크 감지 및 선점 */
SEC("struct_ops/enqueue")
void BPF_PROG(my_enqueue, struct task_struct *p, u64 enq_flags)
{
struct task_ctx *tctx;
u64 slice;
u64 flags = enq_flags;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (!tctx) {
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
return;
}
/* 대화형 태스크 판별 */
tctx->is_interactive = detect_interactive(tctx);
if (tctx->is_interactive) {
/* 짧은 슬라이스 + 선점 플래그 */
slice = 3 * NSEC_PER_MSEC;
flags |= SCX_ENQ_PREEMPT;
/* 현재 CPU에서 실행 중인 태스크가 있으면 선점 */
scx_bpf_kick_cpu(scx_bpf_task_cpu(p), SCX_KICK_PREEMPT);
} else {
/* 일반 태스크: 기본 슬라이스 */
slice = 20 * NSEC_PER_MSEC;
}
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, slice, flags);
}
/* stopping 콜백: 실행 통계 업데이트 */
SEC("struct_ops/stopping")
void BPF_PROG(my_stopping, struct task_struct *p, bool runnable)
{
struct task_ctx *tctx;
u64 now = bpf_ktime_get_ns();
u64 runtime;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (!tctx)
return;
runtime = now - tctx->last_run_at;
tctx->avg_runtime_ns = ewma_update(tctx->avg_runtime_ns, runtime);
if (runnable)
tctx->involuntary_switches++;
else
tctx->voluntary_switches++;
}
/* running 콜백: 수면 시간 측정 */
SEC("struct_ops/running")
void BPF_PROG(my_running, struct task_struct *p)
{
struct task_ctx *tctx;
u64 now = bpf_ktime_get_ns();
u64 sleep_time;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (!tctx)
return;
sleep_time = now - tctx->last_wake_at;
tctx->avg_sleep_ns = ewma_update(tctx->avg_sleep_ns, sleep_time);
tctx->last_run_at = now;
}
SCX_KICK_PREEMPT는 IPI(Inter-Processor Interrupt)를 발생시키므로, 빈번하게 사용하면 성능에 부정적 영향을 미칩니다. 대화형 태스크 감지 로직이 과도하게 민감하면 불필요한 IPI가 발생하여 처리량(Throughput)이 저하됩니다. 적절한 임계값 조정이 중요합니다.
BPF struct_ops 프레임워크
sched_ext는 BPF struct_ops를 사용하여 커널 함수 포인터를 BPF 프로그램으로 교체합니다. SEC("struct_ops")와 SEC("struct_ops.s")(sleepable) 두 가지 모드가 있습니다.
/* 최소 BPF 스케줄러 구조 (struct_ops 등록) */
#include <scx/common.bpf.h>
SEC("struct_ops/enqueue")
void BPF_PROG(enqueue, struct task_struct *p, u64 enq_flags)
{
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
}
SEC("struct_ops/dispatch")
void BPF_PROG(dispatch, s32 cpu, struct task_struct *prev)
{
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
SEC(".struct_ops.link")
struct sched_ext_ops my_ops = {
.enqueue = (void *)enqueue,
.dispatch = (void *)dispatch,
.name = "my_scheduler",
};
SEC(".struct_ops.link")를 사용하면 BPF 프로그램 로드 시 자동으로 struct_ops가 커널에 등록됩니다. SEC(".struct_ops")만 사용하면 별도의 bpf_map__attach_struct_ops() 호출이 필요합니다.
sched_ext BPF 헬퍼 함수
sched_ext는 BPF 프로그램에서 사용할 수 있는 전용 kfunc(커널 함수)을 제공합니다. 이 함수들은 DSQ 관리, CPU 제어, 태스크 정보 조회에 사용됩니다.
| 헬퍼 함수 | 용도 | 호출 가능 시점 |
|---|---|---|
scx_bpf_dsq_insert() | 태스크를 DSQ에 FIFO 삽입 | enqueue, select_cpu |
scx_bpf_dsq_insert_vtime() | 태스크를 DSQ에 vtime 삽입 | enqueue, select_cpu |
scx_bpf_consume() | DSQ에서 태스크를 Local DSQ로 이동 | dispatch |
scx_bpf_select_cpu_dfl() | 기본 idle CPU 선택 알고리즘 | select_cpu |
scx_bpf_kick_cpu() | 원격 CPU에 재스케줄 요청 | 어디서나 |
scx_bpf_create_dsq() | Custom DSQ 생성 | init |
scx_bpf_destroy_dsq() | Custom DSQ 제거 | exit |
scx_bpf_task_cpu() | 태스크의 현재 CPU 조회 | 어디서나 |
scx_bpf_dsq_nr_queued() | DSQ의 대기 태스크 수 | 어디서나 |
scx_bpf_task_cgroup() | 태스크의 cgroup 조회 | 어디서나 |
/* kfunc 선언 (include/linux/sched/ext.h) */
void scx_bpf_dsq_insert(struct task_struct *p, u64 dsq_id,
u64 slice, u64 enq_flags) __ksym;
void scx_bpf_dsq_insert_vtime(struct task_struct *p, u64 dsq_id,
u64 slice, u64 vtime,
u64 enq_flags) __ksym;
bool scx_bpf_consume(u64 dsq_id) __ksym;
s32 scx_bpf_select_cpu_dfl(struct task_struct *p,
s32 prev_cpu, u64 wake_flags,
bool *is_idle) __ksym;
void scx_bpf_kick_cpu(s32 cpu, u64 flags) __ksym;
s32 scx_bpf_create_dsq(u64 dsq_id, s32 node) __ksym;
BPF 맵과 데이터 공유
BPF 스케줄러는 BPF 맵을 통해 per-task 데이터, per-CPU 통계, 유저스페이스와의 통신을 관리합니다.
/* per-task 스토리지 (task_storage 맵) */
struct task_ctx {
u64 last_run_at; /* 마지막 실행 시작 시각 */
u64 total_runtime; /* 누적 실행 시간 */
u64 vtime; /* 가상 시간 (WFQ용) */
u32 weight; /* nice → 가중치 */
bool interactive; /* 대화형 태스크 여부 */
};
struct {
__uint(type, BPF_MAP_TYPE_TASK_STORAGE);
__uint(map_flags, BPF_F_NO_PREALLOC);
__type(key, int);
__type(value, struct task_ctx);
} task_data SEC(".maps");
/* per-CPU 스토리지 */
struct cpu_ctx {
u64 nr_dispatched; /* 디스패치된 태스크 수 */
u64 nr_idle; /* idle 진입 횟수 */
};
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, struct cpu_ctx);
} cpu_data SEC(".maps");
/* 유저스페이스 ↔ BPF 통신 (ring buffer) */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 4096);
} events SEC(".maps");
/* per-task 스토리지 사용 예시 */
SEC("struct_ops/running")
void BPF_PROG(my_running, struct task_struct *p)
{
struct task_ctx *tctx;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (tctx)
tctx->last_run_at = bpf_ktime_get_ns();
}
SEC("struct_ops/stopping")
void BPF_PROG(my_stopping, struct task_struct *p, bool runnable)
{
struct task_ctx *tctx;
u64 delta;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (tctx) {
delta = bpf_ktime_get_ns() - tctx->last_run_at;
tctx->total_runtime += delta;
/* vtime 갱신: 실행 시간 / 가중치 */
tctx->vtime += delta / tctx->weight;
}
}
코드 설명
ops.running과 ops.stopping 콜백을 활용하여 per-task 통계를 수집하는 예시입니다. 이 두 콜백은 태스크가 CPU에서 실행을 시작/중단할 때마다 호출됩니다.
- running 콜백태스크가 CPU에서 실행을 시작하는 시점에 호출됩니다.
bpf_task_storage_get()으로 per-task 저장소를 조회하고,bpf_ktime_get_ns()로 현재 시각을last_run_at에 기록합니다. - stopping 콜백태스크가 CPU를 양보하는 시점에 호출됩니다.
runnable매개변수가true이면 태스크가 여전히 실행 가능 상태(재스케줄)이고,false이면 sleep/exit으로 비활성됩니다. - delta 계산현재 시각과
last_run_at의 차이로 이번 실행 구간의 CPU 사용 시간을 산출합니다. 이 값을total_runtime에 누적합니다. - vtime 갱신가상 시간을
delta / weight로 갱신합니다. 가중치(weight)가 높은 태스크는 같은 실행 시간에 대해 더 적은 vtime을 소비하여, 가중 공정 큐잉(WFQ)을 구현합니다.
task_struct에 직접 연결하므로, 해시(Hash) 맵보다 조회가 빠르고 태스크 종료 시 자동으로 정리됩니다. sched_ext 스케줄러에서 가장 권장되는 per-task 데이터 저장 방식입니다.
BPF verifier와 sched_ext
BPF verifier는 sched_ext 프로그램에 대해 일반 BPF 프로그램보다 엄격한 검증을 수행합니다. 스케줄러는 커널의 핵심 경로에서 실행되므로, 잘못된 BPF 프로그램이 시스템 전체를 불안정하게 만들 수 있기 때문입니다. verifier는 각 ops 콜백의 컨텍스트(Context)에 따라 호출 가능한 kfunc를 제한하며, sleepable/non-sleepable 구분을 강제합니다.
| 검증 항목 | 설명 | 위반 시 결과 |
|---|---|---|
| kfunc 컨텍스트 마스크 | 각 콜백에서 호출 가능한 kfunc를 SCX_KF_* 마스크로 제한 | verifier 로드 거부 |
| sleepable 구분 | SEC("struct_ops.s") 콜백만 sleeping 허용 | non-sleepable에서 sleep 시도 시 거부 |
| 태스크 포인터 유효성 | task_struct 포인터의 lifetime 추적 | 해제된 태스크 접근 시 거부 |
| DSQ ID 범위 | 존재하지 않는 DSQ ID 사용 감지 | 런타임 에러 → CFS 폴백 |
| 무한 루프 방지 | bounded loop만 허용 (bpf_loop 또는 정적 상한) | verifier 로드 거부 |
| 스택(Stack) 크기 | BPF 스택 512바이트 제한 | 스택 초과 시 거부 |
SCX_KF_* kfunc 컨텍스트 마스크
sched_ext는 각 ops 콜백을 호출할 때 scx_kf_allowed 비트마스크를 설정합니다. kfunc 호출 시 이 마스크를 확인하여, 해당 콜백에서 허용된 kfunc만 호출할 수 있도록 강제합니다.
sleepable vs non-sleepable 콜백
sched_ext ops 콜백은 두 가지 모드로 나뉩니다. non-sleepable 콜백은 스케줄링 핫패스에서 실행되며 rq lock을 잡은 상태이므로 절대 sleep할 수 없습니다. sleepable 콜백은 SEC("struct_ops.s")로 선언하며, 초기화/정리 작업에 사용됩니다.
| 구분 | 콜백 | SEC 선언 | 특성 |
|---|---|---|---|
| non-sleepable | select_cpu | SEC("struct_ops") | rq lock 보유, 매우 빠른 경로 |
enqueue | SEC("struct_ops") | rq lock 보유, DSQ 삽입 | |
dispatch | SEC("struct_ops") | rq lock 보유, 태스크 소비 | |
running | SEC("struct_ops") | rq lock 보유, 통계 수집 | |
stopping | SEC("struct_ops") | rq lock 보유, 정리 | |
tick | SEC("struct_ops") | 타이머 인터럽트 컨텍스트 | |
| sleepable | init | SEC("struct_ops.s") | DSQ 생성, 메모리 할당 가능 |
exit | SEC("struct_ops.s") | 리소스 정리, 로그 출력 가능 | |
init_task | SEC("struct_ops.s") | per-task 초기화, 메모리 할당 가능 | |
exit_task | SEC("struct_ops.s") | per-task 리소스 정리 |
/* kernel/sched/ext.c — kfunc 컨텍스트 검증 */
#define SCX_KF_ENQUEUE (1U << 0)
#define SCX_KF_DISPATCH (1U << 1)
#define SCX_KF_CPU_RELEASE (1U << 2)
#define SCX_KF_SLEEPABLE (1U << 3)
#define SCX_KF_TERMINAL (1U << 4)
/*
* kfunc 호출 시 컨텍스트 검증 함수
* 각 kfunc는 allowed_mask를 선언하고,
* 호출 시 현재 ops의 scx_kf_allowed와 AND 연산으로 검증
*/
static bool scx_kf_allowed_on_arg_tasks(
u32 mask, struct task_struct *p)
{
if (unlikely(!(mask & scx_kf_allowed())))
return false;
return true;
}
/*
* scx_bpf_dsq_insert()는 SCX_KF_ENQUEUE | SCX_KF_DISPATCH에서만 허용
* → select_cpu, enqueue, dispatch 콜백에서만 호출 가능
*/
__bpf_kfunc void scx_bpf_dsq_insert(
struct task_struct *p, u64 dsq_id,
u64 slice, u64 enq_flags)
{
if (!scx_kf_allowed(SCX_KF_ENQUEUE | SCX_KF_DISPATCH))
return;
/* ... 실제 삽입 로직 ... */
}
/*
* scx_bpf_create_dsq()는 SCX_KF_SLEEPABLE에서만 허용
* → init() 콜백에서만 호출 가능 (메모리 할당 필요)
*/
__bpf_kfunc s32 scx_bpf_create_dsq(
u64 dsq_id, s32 node)
{
if (!scx_kf_allowed(SCX_KF_SLEEPABLE))
return -EPERM;
/* ... DSQ 생성 ... */
}
verifier 에러 예시와 해결
sched_ext BPF 프로그램을 로드할 때 자주 발생하는 verifier 에러와 해결 방법입니다.
| 에러 메시지 | 원인 | 해결 방법 |
|---|---|---|
calling scx_bpf_dsq_insert() is not allowed | tick/running/stopping에서 dsq_insert 호출 | enqueue 또는 dispatch 콜백으로 이동 |
calling scx_bpf_consume() is not allowed | dispatch 외 콜백에서 consume 호출 | dispatch 콜백에서만 호출 |
cannot call sleepable kfunc in non-sleepable | SEC("struct_ops")에서 sleep 가능 kfunc 호출 | SEC("struct_ops.s")로 변경 또는 로직 분리 |
back-edge from insn N to insn M | 무한 루프 가능성 | bpf_loop() 또는 정적 상한 사용 |
stack depth N exceeds 512 | 함수 호출 체인이 깊어 스택 초과 | 인라인 함수 줄이기, 전역 변수 활용 |
R1 type=scalar expected=ptr_to_btf_id | NULL 체크 누락 또는 타입 불일치 | NULL 체크 추가, BTF 타입 확인 |
/* 에러 예시 1: dispatch 외에서 consume 호출 — 거부됨 */
SEC("struct_ops/enqueue") /* ✗ enqueue에서 consume 불가 */
void BPF_PROG(bad_enqueue, struct task_struct *p, u64 enq_flags)
{
scx_bpf_consume(SCX_DSQ_GLOBAL); /* verifier 에러! */
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
}
/* 해결: consume은 dispatch에서만 사용 */
SEC("struct_ops/enqueue") /* ✓ 올바른 enqueue */
void BPF_PROG(good_enqueue, struct task_struct *p, u64 enq_flags)
{
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
}
SEC("struct_ops/dispatch") /* ✓ consume은 여기서 */
void BPF_PROG(good_dispatch, s32 cpu, struct task_struct *prev)
{
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
/* 에러 예시 2: non-sleepable에서 DSQ 생성 — 거부됨 */
SEC("struct_ops/enqueue") /* ✗ non-sleepable! */
void BPF_PROG(bad_enqueue2, struct task_struct *p, u64 enq_flags)
{
scx_bpf_create_dsq(42, -1); /* verifier 에러! */
}
/* 해결: DSQ 생성은 sleepable init에서 수행 */
SEC("struct_ops.s/init") /* ✓ sleepable */
s32 BPF_PROG(good_init)
{
return scx_bpf_create_dsq(42, -1); /* 정상 */
}
/* 에러 예시 3: bounded loop 필요 */
SEC("struct_ops/dispatch")
void BPF_PROG(bad_dispatch, s32 cpu, struct task_struct *prev)
{
/* ✗ 무한 루프 가능 → verifier 거부 */
while (!scx_bpf_consume(SCX_DSQ_GLOBAL))
; /* busy wait — 거부됨 */
}
/* 해결: bpf_loop 또는 단일 consume */
SEC("struct_ops/dispatch")
void BPF_PROG(good_dispatch, s32 cpu, struct task_struct *prev)
{
/* ✓ 단일 consume 시도 */
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
bpftool prog load <파일> /sys/fs/bpf/test type struct_ops로 상세 로그를 확인할 수 있습니다. -d 플래그를 추가하면 verifier의 상태 추적 과정을 볼 수 있어 정확한 에러 원인 파악에 도움이 됩니다. 또한 BPF_LOG_LEVEL을 높여 더 상세한 출력을 얻을 수 있습니다.
에러 처리와 안전 장치
sched_ext의 가장 중요한 설계 원칙 중 하나는 안전한 실패(fail-safe)입니다. BPF 스케줄러에 문제가 발생하면 시스템이 자동으로 CFS/EEVDF로 복구됩니다.
/* 에러 종류와 exit_kind */
enum scx_exit_kind {
SCX_EXIT_NONE, /* 정상 */
SCX_EXIT_DONE, /* 정상 종료 */
SCX_EXIT_UNREG, /* 유저스페이스 요청 해제 */
SCX_EXIT_UNREG_BPF, /* BPF에서 요청 해제 */
SCX_EXIT_UNREG_KERN, /* 커널에서 요청 해제 */
SCX_EXIT_ERROR, /* 일반 에러 */
SCX_EXIT_ERROR_BPF, /* BPF 프로그램 에러 */
SCX_EXIT_ERROR_STALL, /* watchdog 스톨 감지 */
};
/* exit 콜백에서 종료 사유 처리 */
SEC("struct_ops/exit")
void BPF_PROG(my_exit, struct scx_exit_info *ei)
{
/* 종료 사유를 링 버퍼로 유저스페이스에 전달 */
bpf_printk("sched_ext exit: kind=%d reason=%s",
ei->kind, ei->reason);
}
/* BPF 내부에서 스케줄러 자진 종료 */
if (error_condition)
scx_bpf_exit(SCX_EXIT_ERROR, "fatal error: %d", err);
코드 설명
include/linux/sched/ext.h에 정의된 에러/종료 종류와 ops.exit 콜백의 구현 예시입니다. sched_ext의 안전한 폴백 메커니즘의 핵심입니다.
- scx_exit_kind 열거형스케줄러 종료 사유를 분류합니다.
DONE은 정상 종료,UNREG_*은 해제 요청,ERROR_*은 에러 상황입니다. 커널은 에러 종류에 따라 자동으로 CFS 폴백을 수행합니다. - SCX_EXIT_ERROR_STALLwatchdog이 감지한 스톨 에러입니다.
ops.timeout_ms(기본 30초) 내에 태스크가 디스패치되지 않으면 발생합니다. - ops.exit 콜백스케줄러 종료 시 호출됩니다.
scx_exit_info구조체의kind와reason필드로 종료 사유를 확인하고, 유저스페이스에 전달합니다. - scx_bpf_exit()BPF 프로그램 내부에서 스케줄러를 자진 종료하는 kfunc입니다. 복구 불가능한 에러 상황에서 호출하면 커널이 안전하게 CFS로 전환합니다.
SCX_EXIT_ERROR_STALL을 발생시킵니다. 기본 타임아웃은 30초이며, ops.timeout_ms로 조정할 수 있습니다. 스톨은 보통 dispatch에서 scx_bpf_consume()을 호출하지 않거나, 잘못된 DSQ ID를 사용할 때 발생합니다.
scx_simple (최소 구현, 학습용)
scx_simple은 sched_ext의 가장 기본적인 스케줄러입니다. 모든 태스크를 Global DSQ에 넣고, 어떤 CPU든 소비하는 단순한 구조로, sched_ext 학습의 출발점입니다.
/* scx_simple — 최소 BPF 스케줄러 */
#include <scx/common.bpf.h>
char _license[] SEC("license") = "GPL";
/* FIFO 모드: Global DSQ에 삽입 */
SEC("struct_ops/enqueue")
void BPF_PROG(simple_enqueue, struct task_struct *p,
u64 enq_flags)
{
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL,
enq_flags);
}
/* dispatch: Global DSQ에서 소비 */
SEC("struct_ops/dispatch")
void BPF_PROG(simple_dispatch, s32 cpu,
struct task_struct *prev)
{
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
/* init: 간단한 초기화 */
SEC("struct_ops/init")
s32 BPF_PROG(simple_init)
{
return 0;
}
SEC(".struct_ops.link")
struct sched_ext_ops simple_ops = {
.enqueue = (void *)simple_enqueue,
.dispatch = (void *)simple_dispatch,
.init = (void *)simple_init,
.name = "simple",
};
scx_rusty (Rust, NUMA-aware, 로드 밸런서)
scx_rusty는 Rust로 작성된 NUMA-aware 스케줄러입니다. NUMA 도메인별로 DSQ를 분리하고, 유저스페이스 로드 밸런서가 주기적으로 도메인 간 부하를 재분배합니다.
| 구성요소 | 역할 |
|---|---|
| BPF 스케줄러 | per-NUMA DSQ에 태스크 삽입, vtime 기반 공정 스케줄링 |
| Rust 로드 밸런서 | 10ms 주기로 도메인 간 부하 비교, 과부하 도메인에서 유휴 도메인으로 태스크 이동 |
| NUMA 토폴로지(Topology) | /sys/devices/system/node/에서 자동 감지 |
| LLC 인식 | 같은 LLC(Last-Level Cache) 내 CPU를 우선 선택하여 캐시(Cache) 히트율 극대화 |
// scx_rusty — Rust 유저스페이스 로드 밸런서 (개요)
fn balance_domains(&mut self) {
for dom in &self.domains {
let load = dom.get_load();
let avg = self.avg_load();
if load > avg * 1.2 {
// 과부하 도메인 → 유휴 도메인으로 이동
let target = self.find_underloaded_domain();
self.migrate_tasks(dom, target);
}
}
}
밸런싱 알고리즘 상세
scx_rusty의 로드 밸런싱은 하이스테리시스(Hysteresis) 기반으로 동작합니다. 도메인 간 부하 차이가 일정 임계값을 초과해야만 태스크 이동이 발생하며, 이동 후에도 안정 구간(dead zone)을 두어 불필요한 왕복 이동(ping-pong)을 방지합니다.
| 파라미터 | 기본값 | 역할 |
|---|---|---|
LOAD_IMBAL_HIGH_RATIO | 1.2 (120%) | 과부하 판정 임계값 (평균 대비) |
LOAD_IMBAL_LOW_RATIO | 0.8 (80%) | 저부하 판정 임계값 (평균 대비) |
BALANCE_INTERVAL_MS | 10ms | 밸런싱 주기 |
GREEDY_THRESHOLD | 1.5 (150%) | eager 마이그레이션(Migration) 발동 임계값 |
MAX_MIGRATIONS_PER_ROUND | 도메인당 8 | 한 번의 밸런싱에서 최대 이동 태스크 수 |
// scx_rusty — 상세 밸런싱 알고리즘
const LOAD_IMBAL_HIGH_RATIO: f64 = 1.2;
const LOAD_IMBAL_LOW_RATIO: f64 = 0.8;
const GREEDY_THRESHOLD: f64 = 1.5;
const MAX_MIGRATIONS: usize = 8;
struct Domain {
id: usize,
cpus: Vec<usize>,
load: f64, // 현재 로드 (EWMA)
nr_running: u64, // 실행 가능 태스크 수
nr_cpus: u32, // 도메인 내 CPU 수
tasks: Vec<TaskStat>, // 태스크 통계
}
impl LoadBalancer {
fn balance_step(&mut self) {
let global_avg = self.calc_global_avg_load();
// 1단계: 도메인별 부하 상태 분류
let mut overloaded: Vec<&mut Domain> = Vec::new();
let mut underloaded: Vec<&mut Domain> = Vec::new();
for dom in &mut self.domains {
let ratio = dom.load / global_avg;
if ratio > LOAD_IMBAL_HIGH_RATIO {
overloaded.push(dom);
} else if ratio < LOAD_IMBAL_LOW_RATIO {
underloaded.push(dom);
}
// 중간 영역(dead zone): 이동 없음
}
// 2단계: greedy 마이그레이션 (극심한 불균형)
for src in &mut overloaded {
if src.load / global_avg > GREEDY_THRESHOLD {
// NUMA 거리 기반 가장 가까운 저부하 도메인 선택
if let Some(dst) = self.nearest_underloaded(src) {
self.migrate_greedy(src, dst, MAX_MIGRATIONS);
}
}
}
// 3단계: 일반 밸런싱 (완만한 이동)
for src in &mut overloaded {
for dst in &mut underloaded {
let migrate_count = self.calc_migrate_count(src, dst);
self.migrate_tasks_sorted(src, dst, migrate_count);
}
}
}
// 로드 계산: 가중 평균 (CPU-bound 태스크에 높은 가중치)
fn calc_global_avg_load(&self) -> f64 {
let total: f64 = self.domains.iter()
.map(|d| d.load).sum();
total / self.domains.len() as f64
}
// 이동 태스크 정렬: LLC miss가 높은 태스크를 우선 이동
fn migrate_tasks_sorted(
&mut self,
src: &mut Domain,
dst: &mut Domain,
count: usize,
) {
// LLC miss가 높은 태스크 = 이동 비용 낮음
src.tasks.sort_by(|a, b|
b.llc_miss_rate.partial_cmp(&a.llc_miss_rate)
.unwrap()
);
for task in src.tasks.drain(..min(count, src.tasks.len())) {
self.push_migration(task.pid, dst.id);
}
}
}
BPF 맵 구조
scx_rusty는 BPF 맵을 통해 커널과 유저스페이스 간에 도메인 정보와 통계를 교환합니다.
/* scx_rusty BPF 맵 구조 */
/* CPU → 도메인 매핑 */
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__type(key, u32); /* CPU ID */
__type(value, u32); /* 도메인 ID */
__uint(max_entries, 1024);
} cpu_dom_map SEC(".maps");
/* 도메인별 통계 */
struct dom_stat {
u64 nr_running; /* 실행 가능 태스크 수 */
u64 load_sum; /* 누적 로드 */
u64 nr_migrations_in; /* 유입된 태스크 수 */
u64 nr_migrations_out; /* 유출된 태스크 수 */
};
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__type(key, u32); /* 도메인 ID */
__type(value, struct dom_stat);
__uint(max_entries, 64);
} dom_stats SEC(".maps");
/* 태스크 이동 요청 큐 (유저스페이스 → BPF) */
struct migration_req {
u32 pid;
u32 target_dom_id;
};
struct {
__uint(type, BPF_MAP_TYPE_QUEUE);
__type(value, struct migration_req);
__uint(max_entries, 4096);
} migration_queue SEC(".maps");
/* enqueue에서 도메인 기반 DSQ 선택 */
SEC("struct_ops/enqueue")
void BPF_PROG(rusty_enqueue, struct task_struct *p, u64 enq_flags)
{
s32 cpu = scx_bpf_task_cpu(p);
u32 *dom_id;
u64 dsq_id;
dom_id = bpf_map_lookup_elem(&cpu_dom_map, &cpu);
if (!dom_id) {
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
return;
}
/* 도메인 ID를 DSQ ID로 사용 */
dsq_id = (u64)*dom_id;
scx_bpf_dsq_insert_vtime(p, dsq_id, SCX_SLICE_DFL,
p->scx.dsq_vtime, enq_flags);
}
scx_lavd (Latency-Aware Virtual Deadline)
scx_lavd는 지연 시간에 민감한 워크로드를 위한 스케줄러입니다. 가상 데드라인(Virtual Deadline)을 기반으로 대화형 태스크에 높은 우선순위를 부여하면서도, CPU-bound 태스크의 공정성(Fairness)을 유지합니다.
/* scx_lavd — 가상 데드라인 계산 핵심 로직 */
static u64 calc_virtual_deadline(struct task_ctx *tctx)
{
u64 now = bpf_ktime_get_ns();
u64 avg_runtime = tctx->avg_runtime;
u64 lat_weight = tctx->latency_weight;
/*
* 대화형 태스크: lat_weight 작음 → deadline 빠름
* CPU-bound: lat_weight 큼 → deadline 늦음
* → EDF(Earliest Deadline First) 순서로 실행
*/
return now + (lat_weight * avg_runtime);
}
/* enqueue 시 vtime에 deadline을 설정 */
scx_bpf_dsq_insert_vtime(p, dsq_id, slice,
calc_virtual_deadline(tctx), 0);
latency_weight 계산 공식
latency_weight는 태스크의 지연 민감도를 나타내는 핵심 지표입니다. 대화형 태스크는 낮은 값을, CPU-bound 태스크는 높은 값을 갖습니다. 이 값은 여러 요인을 종합하여 계산됩니다.
| 요인 | 측정 방법 | latency_weight 영향 |
|---|---|---|
| 자발적 전환 비율 | voluntary_csw / total_csw | 높을수록 weight 감소 (대화형) |
| 수면 시간 비율 | sleep_time / (sleep_time + run_time) | 높을수록 weight 감소 (대화형) |
| 평균 실행 시간 | EWMA 기반 avg_runtime | 짧을수록 weight 감소 (대화형) |
| wait 시간 | runqueue 대기 시간 EWMA | 길수록 보상을 위해 weight 감소 |
| CPU 집약도 | run_time / wall_time | 높을수록 weight 증가 (CPU-bound) |
/* scx_lavd — latency_weight 상세 계산 */
#define LAT_WEIGHT_BASE 1000 /* 기본 가중치 */
#define LAT_WEIGHT_MIN 100 /* 최소 (가장 대화형) */
#define LAT_WEIGHT_MAX 10000 /* 최대 (가장 CPU-bound) */
#define EWMA_DECAY 8 /* EWMA 감쇠 상수 (1/8) */
struct task_ctx {
u64 avg_runtime; /* EWMA 평균 실행 시간 (ns) */
u64 avg_sleep_time; /* EWMA 평균 수면 시간 (ns) */
u64 avg_wait_time; /* EWMA 평균 대기 시간 (ns) */
u64 run_time_total; /* 총 실행 시간 */
u64 wall_time_total; /* 총 경과 시간 */
u32 voluntary_csw; /* 자발적 전환 누적 */
u32 total_csw; /* 전체 전환 누적 */
u64 latency_weight; /* 계산된 지연 가중치 */
u64 last_run_at;
u64 last_sleep_at;
u64 last_enqueue_at;
};
/*
* EWMA(Exponentially Weighted Moving Average) 업데이트
* new_avg = old_avg * (1 - 1/DECAY) + sample * (1/DECAY)
* = old_avg - old_avg/DECAY + sample/DECAY
* = (old_avg * (DECAY - 1) + sample) / DECAY
*/
static inline u64 ewma(u64 old_avg, u64 sample)
{
return (old_avg * (EWMA_DECAY - 1) + sample) / EWMA_DECAY;
}
/* latency_weight 계산: 여러 요인의 가중 합산 */
static u64 calc_latency_weight(struct task_ctx *tctx)
{
u64 weight = LAT_WEIGHT_BASE;
u64 vol_ratio, sleep_ratio, cpu_intensity;
/* 요인 1: 자발적 전환 비율 (0~100) */
if (tctx->total_csw > 0) {
vol_ratio = tctx->voluntary_csw * 100 / tctx->total_csw;
/* 자발적 전환이 많을수록 weight 감소 */
weight = weight * (200 - vol_ratio) / 100;
}
/* 요인 2: 수면/실행 비율 */
if (tctx->avg_runtime > 0) {
sleep_ratio = tctx->avg_sleep_time * 100 /
(tctx->avg_sleep_time + tctx->avg_runtime);
/* 수면 비율이 높을수록 weight 감소 */
weight = weight * (200 - sleep_ratio) / 100;
}
/* 요인 3: CPU 집약도 */
if (tctx->wall_time_total > 0) {
cpu_intensity = tctx->run_time_total * 100 /
tctx->wall_time_total;
/* CPU 집약도가 높을수록 weight 증가 */
weight = weight * (100 + cpu_intensity) / 100;
}
/* 대기 시간 보상: 오래 기다린 태스크에 보너스 */
if (tctx->avg_wait_time > 5 * NSEC_PER_MSEC)
weight = weight * 80 / 100; /* 20% 감소 */
/* 범위 제한 */
if (weight < LAT_WEIGHT_MIN) weight = LAT_WEIGHT_MIN;
if (weight > LAT_WEIGHT_MAX) weight = LAT_WEIGHT_MAX;
return weight;
}
/* stopping 콜백: 런타임/대기 시간 EWMA 업데이트 */
SEC("struct_ops/stopping")
void BPF_PROG(lavd_stopping, struct task_struct *p, bool runnable)
{
struct task_ctx *tctx;
u64 now = bpf_ktime_get_ns();
u64 runtime;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (!tctx) return;
runtime = now - tctx->last_run_at;
tctx->avg_runtime = ewma(tctx->avg_runtime, runtime);
tctx->run_time_total += runtime;
tctx->wall_time_total += now - tctx->last_enqueue_at;
tctx->total_csw++;
if (!runnable)
tctx->voluntary_csw++;
/* latency_weight 재계산 */
tctx->latency_weight = calc_latency_weight(tctx);
}
/* running 콜백: 수면/대기 시간 측정 */
SEC("struct_ops/running")
void BPF_PROG(lavd_running, struct task_struct *p)
{
struct task_ctx *tctx;
u64 now = bpf_ktime_get_ns();
u64 sleep_time, wait_time;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (!tctx) return;
/* 수면 시간: 마지막 블록 ~ 마지막 enqueue */
if (tctx->last_sleep_at > 0 &&
tctx->last_enqueue_at > tctx->last_sleep_at) {
sleep_time = tctx->last_enqueue_at - tctx->last_sleep_at;
tctx->avg_sleep_time = ewma(tctx->avg_sleep_time, sleep_time);
}
/* 대기 시간: enqueue ~ running */
wait_time = now - tctx->last_enqueue_at;
tctx->avg_wait_time = ewma(tctx->avg_wait_time, wait_time);
tctx->last_run_at = now;
}
scx_rustland (유저스페이스 스케줄링)
scx_rustland는 BPF가 태스크를 유저스페이스 Rust 프로그램으로 전달하고, 유저스페이스에서 스케줄링 결정을 내리는 독특한 구조입니다. BPF는 태스크를 링 버퍼(Ring Buffer)로 전달하는 중개 역할만 합니다.
| 구성요소 | 역할 |
|---|---|
| BPF 프로그램 | 태스크를 유저스페이스 링 버퍼로 전달, 결과를 수신하여 DSQ에 삽입 |
| Rust 스케줄러 | 스케줄링 결정 (CPU 선택, 우선순위 계산) |
| BPF 링 버퍼 | 커널 ↔ 유저스페이스 양방향 통신 |
// scx_rustland — 유저스페이스 스케줄러 구조
struct Scheduler {
bpf: BpfScheduler,
task_pool: BTreeMap<u64, TaskInfo>,
}
impl Scheduler {
fn dispatch_tasks(&mut self) {
// BPF 링 버퍼에서 대기 태스크 수신
while let Some(task) = self.bpf.dequeue_task() {
let cpu = self.select_cpu(&task);
let slice = self.calc_slice(&task);
// 스케줄링 결정을 BPF에 전달
self.bpf.dispatch_task(task.pid, cpu, slice);
}
}
fn select_cpu(&self, task: &TaskInfo) -> i32 {
// 이전 CPU 선호 (캐시 지역성)
if self.is_cpu_idle(task.prev_cpu) {
return task.prev_cpu;
}
// 같은 LLC 내 idle CPU 탐색
self.find_idle_cpu_in_llc(task.prev_cpu)
}
}
scx_rustland 내부 아키텍처
scx_rustland의 핵심은 이중 링 버퍼(Dual Ring Buffer) 아키텍처입니다. BPF enqueue 콜백이 태스크 정보를 enqueue_buf에 기록하면 유저스페이스 Rust 스케줄러가 이를 읽어 스케줄링 결정을 내리고, 결과를 dispatch_buf에 기록합니다. BPF dispatch 콜백이 이 결과를 읽어 해당 CPU의 Local DSQ에 태스크를 삽입합니다.
// scx_rustland — 유저스페이스 스케줄링 루프 상세
use std::collections::BTreeMap;
/// 태스크 메타데이터 (BPF에서 수신)
struct QueuedTask {
pid: u64, // 태스크 PID
sum_exec_runtime: u64, // 누적 실행 시간 (ns)
weight: u64, // nice 기반 가중치
prev_cpu: i32, // 마지막 실행 CPU
flags: u64, // SCX_ENQ_* 플래그
}
/// 디스패치 결정 (유저스페이스 → BPF)
struct DispatchedTask {
pid: u64, // 대상 태스크 PID
cpu: i32, // 실행할 CPU (-1 = 임의)
slice_ns: u64, // 타임 슬라이스 (ns)
flags: u64, // 추가 플래그
}
struct Scheduler {
bpf: BpfScheduler,
// vruntime 기반 정렬 — BTreeMap<vruntime, Vec<pid>>
task_pool: BTreeMap<u64, Vec<u64>>,
task_map: HashMap<u64, TaskInfo>,
min_vruntime: u64,
nr_cpus: usize,
}
impl Scheduler {
/// 메인 스케줄링 루프 — BPF에서 태스크를 수신하고 결정을 반환
fn run(&mut self) -> Result<()> {
loop {
// 1단계: BPF 링 버퍼에서 새 태스크 수신
self.drain_queued_tasks();
// 2단계: vruntime 순서대로 태스크 디스패치
self.dispatch_tasks();
// 3단계: 처리할 태스크가 없으면 잠시 대기
if self.task_pool.is_empty() {
self.bpf.wait_for_tasks(100); // 100us timeout
}
}
}
/// BPF 링 버퍼에서 태스크를 읽어 task_pool에 삽입
fn drain_queued_tasks(&mut self) {
while let Some(task) = self.bpf.dequeue_task() {
let vruntime = self.calc_vruntime(&task);
// 새 태스크는 min_vruntime부터 시작 (기아 방지)
let vruntime = vruntime.max(self.min_vruntime);
self.task_pool
.entry(vruntime)
.or_default()
.push(task.pid);
self.task_map.insert(task.pid, TaskInfo {
weight: task.weight,
prev_cpu: task.prev_cpu,
sum_exec: task.sum_exec_runtime,
});
}
}
/// vruntime이 가장 작은 태스크부터 디스패치
fn dispatch_tasks(&mut self) {
let budget = self.nr_cpus * 4; // CPU 수 x 4 배치
for _ in 0..budget {
let mut entry = match self.task_pool.first_entry() {
Some(e) => e,
None => break,
};
let vruntime = *entry.key();
let pids = entry.get_mut();
let pid = pids.pop().unwrap();
if pids.is_empty() {
entry.remove();
}
// min_vruntime 갱신
self.min_vruntime = vruntime;
let info = self.task_map.get(&pid).unwrap();
let cpu = self.select_cpu(info);
let slice = self.calc_slice(info);
// BPF에 디스패치 결정 전달
self.bpf.dispatch_task(pid, cpu, slice);
}
}
/// CPU 선택: 캐시 지역성 최적화
fn select_cpu(&self, info: &TaskInfo) -> i32 {
// 이전 CPU가 idle이면 재사용 (L1/L2 캐시 활용)
if self.bpf.is_cpu_idle(info.prev_cpu) {
return info.prev_cpu;
}
// 같은 LLC 도메인 내 idle CPU 탐색
if let Some(cpu) = self.bpf.find_idle_in_llc(info.prev_cpu) {
return cpu;
}
// 전체 시스템에서 idle CPU 탐색
self.bpf.find_any_idle_cpu().unwrap_or(-1)
}
/// 타임 슬라이스 계산: weight 비례
fn calc_slice(&self, info: &TaskInfo) -> u64 {
let base_slice: u64 = 5_000_000; // 5ms 기본
base_slice * info.weight / 100
}
}
enqueue 콜백에서 p->flags & PF_KTHREAD를 확인하여 분기합니다.
기타 스케줄러
scx 저장소에는 다양한 목적의 스케줄러가 포함되어 있습니다.
| 스케줄러 | 언어 | 특징 | 적합한 워크로드 |
|---|---|---|---|
scx_layered | Rust | 워크로드를 레이어로 분류, 레이어별 정책 적용 | 혼합 워크로드 (웹서버 + 배치) |
scx_nest | C | 코어 컴팩팅, 전력 효율 최적화 | 전력 절약 (노트북, 서버 절전) |
scx_flatcg | C | cgroup 기반 가중치 분배 | 컨테이너 환경 |
scx_central | C | 중앙 CPU가 모든 스케줄링 결정 | 연구/실험 용도 |
scx_pair | C | cgroup 쌍을 동시 스케줄링 | gang scheduling 실험 |
scx_flash | C | 최소 지연 FIFO 스케줄링 | 지연 민감 워크로드 |
scx_layered 상세
scx_layered는 워크로드를 레이어(Layer)로 분류하여 각 레이어에 독립적인 스케줄링 정책을 적용합니다. JSON 설정 파일로 레이어 매칭 규칙과 정책을 정의하며, 각 레이어는 자체 DSQ와 cpumask를 가집니다.
// scx_layered 설정 파일 예시 (layers.json)
{
"layers": [
{
"name": "interactive",
"comment": "대화형 프로세스: 짧은 슬라이스, 선점 허용",
"matches": [
[{ "CommPrefix": "Xorg" }],
[{ "CommPrefix": "gnome-shell" }],
[{ "NiceAbove": 0 }]
],
"kind": {
"Confined": {
"cpus_range": [0, 7],
"util_range": [0.0, 0.8]
}
},
"idle_smt": true,
"preempt": true,
"slice_us": 1000,
"weight": 200
},
{
"name": "batch",
"comment": "배치 처리: 긴 슬라이스, CPU 격리",
"matches": [
[{ "CommPrefix": "gcc" }],
[{ "CommPrefix": "make" }],
[{ "NiceBelow": 0 }]
],
"kind": {
"Grouped": {
"cpus_range": [8, 15],
"preempt": false
}
},
"slice_us": 20000,
"weight": 50
},
{
"name": "default",
"comment": "나머지 모든 태스크",
"matches": [[{ "CgroupPrefix": "/" }]],
"kind": { "Open": { "preempt": false } },
"slice_us": 5000,
"weight": 100
}
]
}
matches 배열은 OR 조건이고, 내부 배열은 AND 조건입니다. 지원하는 매칭 기준: CommPrefix(프로세스 이름 접두사), CgroupPrefix(cgroup 경로), NiceAbove/NiceBelow(nice 값 범위), PIDEquals(특정 PID), TGIDEquals(스레드 그룹 ID). 레이어 kind는 Confined(CPU 범위 제한), Grouped(CPU 그룹핑), Open(전체 CPU 사용 가능)의 세 가지입니다.
scx_nest 상세 — 코어 컴팩팅
scx_nest는 CPU를 Primary(주 코어)와 Reserve(예비 코어)로 나누어, 부하가 낮을 때 Reserve 코어를 깊은 C-state로 진입시켜 전력을 절약합니다. 동시에 Primary 코어의 P-state가 높아져 단일 스레드 성능이 향상되는 효과도 있습니다.
/* scx_nest — 코어 컴팩팅 핵심 로직 */
/* CPU 마스크: primary와 reserve 분리 관리 */
static u64 primary_cpumask[MAX_CPUS / 64];
static u64 reserve_cpumask[MAX_CPUS / 64];
/* per-CPU 통계 */
struct pcpu_ctx {
u64 last_used; /* 마지막 태스크 실행 시점 */
u32 nest_level; /* 0=primary, 1=reserve */
bool in_use; /* 현재 태스크 실행 중 */
};
/* enqueue: primary 코어 우선, 부족 시 reserve 활성화 */
SEC("struct_ops/select_cpu")
s32 BPF_PROG(nest_select_cpu, struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
s32 cpu;
/* 1단계: 이전 CPU가 primary이고 idle이면 재사용 */
if (is_primary(prev_cpu) && scx_bpf_test_and_clear_cpu_idle(prev_cpu))
return prev_cpu;
/* 2단계: primary 마스크 내 idle CPU 탐색 */
cpu = scx_bpf_pick_idle_cpu(primary_cpumask, 0);
if (cpu >= 0)
return cpu;
/* 3단계: primary 포화 → reserve에서 CPU 승격 */
cpu = scx_bpf_pick_idle_cpu(reserve_cpumask, 0);
if (cpu >= 0) {
promote_to_primary(cpu);
return cpu;
}
return prev_cpu;
}
/* compact_timer: 주기적으로 idle primary를 reserve로 강등 */
static void compact_primary(void)
{
u64 now = bpf_ktime_get_ns();
s32 cpu;
bpf_for(cpu, 0, nr_cpus) {
struct pcpu_ctx *ctx = lookup_pcpu_ctx(cpu);
if (!ctx || !is_primary(cpu))
continue;
/* 1초 이상 idle이면 reserve로 강등 */
if (!ctx->in_use &&
now - ctx->last_used > 1000000000ULL) {
demote_to_reserve(cpu);
}
}
}
scx_central 상세 — 중앙 집중 스케줄링
scx_central은 하나의 전용 CPU가 시스템의 모든 스케줄링 결정을 내리는 실험적 모델입니다. 나머지 CPU는 스케줄링 오버헤드가 없이 순수하게 태스크만 실행합니다. 이 모델은 스케줄링 잠금 경합을 완전히 제거하지만, 중앙 CPU가 병목이 될 수 있습니다.
/* scx_central — 핵심 동작 원리 */
/* central CPU에서 주기적으로 실행되는 디스패치 타이머 */
static void central_dispatch_timer(void)
{
s32 cpu;
/* 모든 worker CPU의 Local DSQ를 확인하고 채움 */
bpf_for(cpu, 0, nr_cpus) {
if (cpu == central_cpu)
continue;
/* worker CPU의 Local DSQ가 비었으면 Global에서 이동 */
if (scx_bpf_dsq_nr_queued(SCX_DSQ_LOCAL_ON | cpu) == 0) {
scx_bpf_dsq_move_to_local(SCX_DSQ_GLOBAL);
/* IPI로 worker CPU에 새 태스크 알림 */
scx_bpf_kick_cpu(cpu, 0);
}
}
}
/* worker CPU에서는 tick을 비활성화하여 오버헤드 제거 */
/* SCX_OPS_ENQ_LAST 플래그로 마지막 태스크까지 처리 */
scx_flash 상세 — 최소 지연 FIFO
scx_flash는 순수 FIFO 스케줄링으로 최소 지연을 추구합니다. 모든 태스크를 도착 순서대로 실행하며, 복잡한 가중치 계산이나 vtime 관리가 없어 스케줄링 오버헤드가 극도로 낮습니다. 실시간에 가까운 응답성이 필요하지만 RT 스케줄러를 사용하기 어려운 환경에 적합합니다.
/* scx_flash — 최소 지연 FIFO 스케줄링 핵심 */
/* 모든 태스크를 FIFO로 Local DSQ에 직접 삽입 */
SEC("struct_ops/select_cpu")
s32 BPF_PROG(flash_select_cpu, struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
bool is_idle = false;
s32 cpu;
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
if (is_idle) {
/* idle CPU 발견 → Local DSQ에 직접 FIFO 삽입
* Global DSQ 잠금 우회 → 최소 레이턴시 */
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
}
return cpu;
}
SEC("struct_ops/enqueue")
void BPF_PROG(flash_enqueue, struct task_struct *p, u64 enq_flags)
{
/* select_cpu에서 삽입 안 된 경우만 Global FIFO로 */
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
}
/* dispatch: Global → Local 이동만 수행 */
SEC("struct_ops/dispatch")
void BPF_PROG(flash_dispatch, s32 cpu,
struct task_struct *prev)
{
scx_bpf_dsq_move_to_local(SCX_DSQ_GLOBAL);
}
/*
* scx_flash 성능 특성:
* - 스케줄링 오버헤드: ~50ns (vtime 계산 없음)
* - 공정성: 없음 (순수 FIFO)
* - 적합: 지연 민감 + 짧은 태스크 위주 워크로드
* - 부적합: CPU-bound 혼합 (기아 발생 가능)
*/
개발 환경 설정
sched_ext 개발을 위해서는 v6.12 이상의 커널과 BPF 도구 체인이 필요합니다.
# 1. 커널 빌드 (sched_ext 활성화)
git clone --depth=1 https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
cd linux
# 필수 커널 설정
make menuconfig
# General setup → BPF subsystem → [*] Enable bpf() system call
# General setup → Scheduler → [*] sched_ext extensible scheduler class
# 또는 직접 .config 수정
cat >> .config <<EOF
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_BPF_JIT=y
CONFIG_DEBUG_INFO_BTF=y
CONFIG_SCHED_CLASS_EXT=y
EOF
make -j$(nproc)
sudo make modules_install install
# 2. scx 저장소 클론 및 빌드
git clone https://github.com/sched-ext/scx.git
cd scx
# 의존성 설치 (Ubuntu/Debian)
sudo apt install clang llvm libelf-dev libbpf-dev \
cargo rustc meson ninja-build
# Rust 빌드 (scx_rusty, scx_lavd 등)
cargo build --release
# C 빌드 (scx_simple, scx_central 등)
meson setup build
meson compile -C build
# 3. 스케줄러 실행
# scx_simple 실행
sudo ./build/scheds/c/scx_simple
# scx_rusty 실행 (Rust)
sudo ./target/release/scx_rusty
# scx_lavd 실행
sudo ./target/release/scx_lavd
# 스케줄러 상태 확인
cat /sys/kernel/sched_ext/root/ops
cat /sys/kernel/sched_ext/root/enable
cat /sys/kernel/sched_ext/nr_rejected
sudo dnf install scx-scheds로 사전 빌드된 스케줄러를 설치할 수 있습니다. Ubuntu 24.10+에서도 PPA를 통해 사용 가능합니다.
나만의 BPF 스케줄러 작성
scx_simple을 기반으로 자신만의 BPF 스케줄러를 작성하는 과정을 단계별로 안내합니다.
/* my_scheduler.bpf.c — 가중치 기반 공정 스케줄러 */
#include <scx/common.bpf.h>
char _license[] SEC("license") = "GPL";
/* per-CPU DSQ ID: 각 CPU마다 하나의 DSQ */
#define CPU_DSQ_BASE 100
#define MAX_CPUS 256
/* per-task 가상 시간 */
struct task_ctx {
u64 vtime;
u32 weight;
};
struct {
__uint(type, BPF_MAP_TYPE_TASK_STORAGE);
__uint(map_flags, BPF_F_NO_PREALLOC);
__type(key, int);
__type(value, struct task_ctx);
} task_data SEC(".maps");
/* 전역 vtime 추적 */
u64 vtime_now;
SEC("struct_ops/init")
s32 BPF_PROG(my_init)
{
s32 i;
/* 각 CPU마다 DSQ 생성 */
for (i = 0; i < MAX_CPUS; i++) {
if (scx_bpf_create_dsq(CPU_DSQ_BASE + i, -1) < 0)
break;
}
return 0;
}
SEC("struct_ops/init_task")
s32 BPF_PROG(my_init_task, struct task_struct *p,
struct scx_init_task_args *args)
{
struct task_ctx *tctx;
tctx = bpf_task_storage_get(&task_data, p, 0,
BPF_LOCAL_STORAGE_GET_F_CREATE);
if (!tctx)
return -ENOMEM;
tctx->vtime = vtime_now;
tctx->weight = p->scx.weight;
return 0;
}
SEC("struct_ops/select_cpu")
s32 BPF_PROG(my_select_cpu, struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
bool is_idle = false;
s32 cpu;
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags,
&is_idle);
if (is_idle)
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
SCX_SLICE_DFL, 0);
return cpu;
}
SEC("struct_ops/enqueue")
void BPF_PROG(my_enqueue, struct task_struct *p,
u64 enq_flags)
{
struct task_ctx *tctx;
s32 cpu;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (!tctx) {
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL,
SCX_SLICE_DFL, enq_flags);
return;
}
/* per-CPU DSQ에 vtime 기반 삽입 */
cpu = scx_bpf_task_cpu(p);
scx_bpf_dsq_insert_vtime(p, CPU_DSQ_BASE + cpu,
SCX_SLICE_DFL,
tctx->vtime, enq_flags);
}
SEC("struct_ops/dispatch")
void BPF_PROG(my_dispatch, s32 cpu,
struct task_struct *prev)
{
/* 현재 CPU의 DSQ에서 소비 */
scx_bpf_consume(CPU_DSQ_BASE + cpu);
/* 비었으면 Global DSQ에서 소비 */
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
SEC("struct_ops/stopping")
void BPF_PROG(my_stopping, struct task_struct *p,
bool runnable)
{
struct task_ctx *tctx;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (tctx) {
/* vtime 갱신: 사용한 슬라이스 / 가중치 */
tctx->vtime += (SCX_SLICE_DFL - p->scx.slice)
/ tctx->weight;
}
}
SEC(".struct_ops.link")
struct sched_ext_ops my_ops = {
.init = (void *)my_init,
.init_task = (void *)my_init_task,
.select_cpu = (void *)my_select_cpu,
.enqueue = (void *)my_enqueue,
.dispatch = (void *)my_dispatch,
.stopping = (void *)my_stopping,
.name = "my_scheduler",
};
/* my_scheduler.c — 유저스페이스 로더 */
#include <bpf/libbpf.h>
#include <signal.h>
#include <stdio.h>
static volatile bool running = true;
static void sigint_handler(int sig)
{
running = false;
}
int main(int argc, char **argv)
{
struct my_scheduler_bpf *skel;
struct bpf_link *link;
signal(SIGINT, sigint_handler);
/* BPF 스켈레톤 로드 */
skel = my_scheduler_bpf__open_and_load();
if (!skel)
return 1;
/* struct_ops 등록 */
link = bpf_map__attach_struct_ops(skel->maps.my_ops);
if (!link)
goto cleanup;
printf("스케줄러 활성화됨. Ctrl+C로 종료.\n");
while (running)
sleep(1);
cleanup:
bpf_link__destroy(link);
my_scheduler_bpf__destroy(skel);
return 0;
}
코드 설명
BPF 스케줄러의 유저스페이스 로더(Loader) 프로그램입니다. libbpf를 사용하여 BPF 스켈레톤을 로드하고 struct_ops를 커널에 등록합니다.
- my_scheduler_bpf__open_and_load()bpftool이 생성한 스켈레톤 헤더(
my_scheduler.skel.h)의 함수입니다. BPF 오브젝트를 열고, verifier 검증을 거쳐 커널에 로드합니다. - bpf_map__attach_struct_ops()
sched_ext_ops구조체를 커널에 등록합니다. 이 호출이 성공하면 BPF 스케줄러가 즉시 활성화되어 태스크를 관리하기 시작합니다. 반환된bpf_link는 스케줄러 생명주기를 제어합니다. - SIGINT 핸들러Ctrl+C로 종료 시
running플래그를false로 설정하여 메인 루프를 빠져나갑니다. - bpf_link__destroy()링크를 해제하면 커널이
ops.exit콜백을 호출하고, 모든 태스크를 CFS로 되돌린 뒤 BPF 스케줄러를 비활성화합니다. 프로세스 종료 시에도 자동으로 정리됩니다.
# Makefile — BPF 스케줄러 빌드
BPF_CLANG = clang
BPF_CFLAGS = -target bpf -O2 -g -Wall
all: my_scheduler.bpf.o my_scheduler
my_scheduler.bpf.o: my_scheduler.bpf.c
$(BPF_CLANG) $(BPF_CFLAGS) -c $< -o $@
my_scheduler.skel.h: my_scheduler.bpf.o
bpftool gen skeleton $< > $@
my_scheduler: my_scheduler.c my_scheduler.skel.h
gcc -O2 -o $@ $< -lbpf -lelf -lz
디버깅(Debugging)과 트레이싱
sched_ext 스케줄러의 디버깅에는 sysfs 인터페이스, bpftool, ftrace, perf를 활용합니다.
# sysfs 상태 확인
cat /sys/kernel/sched_ext/root/ops # 현재 활성 스케줄러 이름
cat /sys/kernel/sched_ext/root/enable # 활성화 상태 (1/0)
cat /sys/kernel/sched_ext/nr_rejected # 거부된 태스크 수
# bpftool로 BPF 프로그램 상태 확인
sudo bpftool prog list | grep struct_ops
sudo bpftool struct_ops list
sudo bpftool struct_ops dump name my_ops
# ftrace로 스케줄링 이벤트 추적
echo 1 > /sys/kernel/debug/tracing/events/sched_ext/enable
cat /sys/kernel/debug/tracing/trace_pipe
# sched_ext tracepoint 목록
ls /sys/kernel/debug/tracing/events/sched_ext/
# sched_ext_enqueue, sched_ext_dispatch, sched_ext_cpu_*
# perf로 스케줄링 레이턴시 측정
sudo perf sched record -- sleep 10
sudo perf sched latency --sort max
# BPF 프로그램 로그 확인
sudo cat /sys/kernel/debug/tracing/trace_pipe | grep bpf_trace_printk
/* BPF 프로그램 내 디버깅 출력 */
SEC("struct_ops/enqueue")
void BPF_PROG(debug_enqueue, struct task_struct *p,
u64 enq_flags)
{
/* bpf_printk로 trace_pipe에 출력 */
bpf_printk("enqueue: pid=%d comm=%s cpu=%d",
p->pid, p->comm,
scx_bpf_task_cpu(p));
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL,
SCX_SLICE_DFL, enq_flags);
}
bpf_printk는 디버깅에 유용하지만, 고빈도 경로(enqueue, dispatch)에서 사용하면 심각한 성능 저하를 유발합니다. 프로덕션에서는 BPF 링 버퍼를 사용하여 선택적으로 이벤트를 전달하세요.
성능 벤치마크
sched_ext 스케줄러의 성능은 워크로드에 따라 크게 달라집니다. 아래는 대표적인 벤치마크 결과입니다.
| 벤치마크 | CFS/EEVDF | scx_rusty | scx_lavd | 비고 |
|---|---|---|---|---|
| hackbench (지연) | 기준 | -5~+3% | -8~-15% | lavd가 지연 최적화에 강함 |
| schbench (p99 지연) | 기준 | -10~-20% | -15~-30% | 꼬리 지연 개선 |
| kernbench (처리량(Throughput)) | 기준 | -2~+5% | -3~+2% | CPU-bound에서 유사 |
| 게이밍 (FPS jitter) | 기준 | -20~-40% | -30~-50% | 프레임 일관성 개선 |
| NUMA 4-node (처리량) | 기준 | +5~+15% | +2~+8% | rusty NUMA 인식 효과 |
# 벤치마크 실행 예시
# hackbench — 스케줄러 지연 벤치마크
sudo hackbench -g 20 -l 1000
# schbench — 스케줄러 벤치마크 (p99 지연)
sudo schbench -m 8 -t 16 -s 30000
# kernbench — 커널 빌드 처리량
kernbench -M -H -O -n 5
# CFS와 scx_rusty 비교
# 1. CFS로 벤치마크 (기본)
sudo hackbench -g 20 -l 1000 2>&1 | tee cfs_result.txt
# 2. scx_rusty 로드
sudo scx_rusty &
# 3. scx_rusty로 벤치마크
sudo hackbench -g 20 -l 1000 2>&1 | tee rusty_result.txt
# 4. 결과 비교
diff cfs_result.txt rusty_result.txt
schbench 결과 해석 가이드
schbench는 스케줄러의 꼬리 지연(Tail Latency)을 측정하는 대표적인 도구입니다. 메시지 전달 패턴을 시뮬레이션하여 실제 서버 워크로드와 유사한 부하를 생성합니다.
# schbench 기본 실행 (-m: 메시지 스레드 그룹, -t: 그룹당 스레드, -s: 실행 시간 μs)
sudo schbench -m 8 -t 16 -s 30000
# 출력 예시:
# Latency percentiles (usec)
# 50.0th: 12
# 75.0th: 18
# 90.0th: 26
# 99.0th: 48 ← p99: 요청의 99%가 48μs 이내 완료
# 99.9th: 112 ← p99.9: 극단적 꼬리 지연
# max: 580
| 지표 | 의미 | 좋은 결과 기준 | 주의사항 |
|---|---|---|---|
| p50 (중앙값) | 일반적인 지연 수준 | 10~20μs | 전체적인 스케줄러 효율성 반영 |
| p99 | 꼬리 지연의 핵심 지표 | p50의 3~5배 이내 | SLA/SLO 기준으로 가장 많이 사용 |
| p99.9 | 극단적 꼬리 지연 | p99의 2~3배 이내 | 큰 폭 증가 시 스케줄러 문제 의심 |
| max | 최악의 경우 | p99.9의 5배 이내 | 인터럽트, SMI 등 외부 요인 포함 가능 |
| p99/p50 비율 | 지연 분포의 균일성 | 4 미만 | 비율이 클수록 지연 스파이크가 심함 |
워크로드별 스케줄러 추천
워크로드 특성에 따라 적합한 sched_ext 스케줄러가 다릅니다. 아래 표는 대표적인 시나리오별 추천을 정리한 것입니다.
| 워크로드 시나리오 | 추천 스케줄러 | 핵심 이유 | 주요 설정 |
|---|---|---|---|
| 웹 서버 (p99 지연 최적화) | scx_lavd | LAVD 알고리즘이 지연 민감 태스크 우선 처리 | --performance |
| NUMA 다중 소켓 서버 | scx_rusty | Rust 기반 NUMA 토폴로지 인식 배치 | --balanced |
| 데스크탑/게이밍 | scx_lavd | 대화형 태스크 우선, FPS jitter 감소 | --performance |
| HPC/배치 처리 | CFS/EEVDF | CPU-bound에서 sched_ext 오버헤드 불필요 | 기본 커널 |
| 컨테이너 오케스트레이션 | scx_rusty | cgroup 인식 + NUMA 최적화 | --balanced |
| 데이터베이스 (OLTP) | scx_lavd | 짧은 트랜잭션 지연 최적화 | --performance |
| 빌드 시스템 (make -j) | scx_rusty | NUMA 인식으로 캐시 효율 향상 | --balanced |
| 실시간 오디오/비디오 | scx_lavd | 극단적 꼬리 지연 억제 | --performance |
| 머신러닝 학습 | CFS/EEVDF | GPU-bound, CPU 스케줄러 영향 미미 | 기본 커널 |
| 스케줄링 연구/실험 | scx_central | 중앙집중 모델로 알고리즘 검증 용이 | 커스텀 |
perf stat으로 스케줄러 성능 비교
perf stat을 사용하면 컨텍스트 스위치(Context Switch) 횟수, 캐시 미스(Cache Miss) 비율 등 스케줄러의 저수준 동작을 정량적으로 비교할 수 있습니다.
# 1. CFS 기준 측정
sudo perf stat -e context-switches,cpu-migrations,cache-misses,cache-references,\
instructions,cycles -a -- hackbench -g 20 -l 1000 2>&1 | tee perf_cfs.txt
# 2. scx_rusty 로드 후 측정
sudo scx_rusty &
sudo perf stat -e context-switches,cpu-migrations,cache-misses,cache-references,\
instructions,cycles -a -- hackbench -g 20 -l 1000 2>&1 | tee perf_rusty.txt
# 3. scx_lavd 로드 후 측정
sudo scx_lavd --performance &
sudo perf stat -e context-switches,cpu-migrations,cache-misses,cache-references,\
instructions,cycles -a -- hackbench -g 20 -l 1000 2>&1 | tee perf_lavd.txt
| perf 이벤트 | 의미 | 스케줄러 관련성 | 좋은 방향 |
|---|---|---|---|
context-switches | 컨텍스트 스위치 총 횟수 | 스케줄러 결정 빈도 반영 | 동일 처리량 대비 ↓ 낮을수록 |
cpu-migrations | CPU 간 태스크 이동 횟수 | NUMA/캐시 친화도 반영 | ↓ 낮을수록 (캐시 보존) |
cache-misses | 캐시 미스 횟수 | 태스크 배치 품질 반영 | ↓ 낮을수록 |
cache-references | 캐시 참조 총 횟수 | 미스율 계산 기준 | cache-misses/references ↓ |
instructions | 실행된 명령어 수 | 스케줄러 오버헤드 포함 | 동일 작업 대비 ↓ 낮을수록 |
cycles | CPU 사이클 수 | IPC(Instructions Per Cycle) 계산 | IPC ↑ 높을수록 |
# 캐시 미스율 비교 스크립트
for f in perf_cfs.txt perf_rusty.txt perf_lavd.txt; do
echo "=== $f ==="
misses=$(grep 'cache-misses' "$f" | awk '{print $1}' | tr -d ',')
refs=$(grep 'cache-references' "$f" | awk '{print $1}' | tr -d ',')
ctx=$(grep 'context-switches' "$f" | awk '{print $1}' | tr -d ',')
mig=$(grep 'cpu-migrations' "$f" | awk '{print $1}' | tr -d ',')
echo " 컨텍스트 스위치: $ctx"
echo " CPU 마이그레이션: $mig"
echo " 캐시 미스율: $(echo "scale=2; $misses * 100 / $refs" | bc)%"
done
cpu-migrations가 CFS 대비 크게 감소하는 것이 일반적입니다. 이는 NUMA 인식 배치의 직접적인 효과입니다. scx_lavd는 context-switches가 증가할 수 있지만, 각 스위치의 지연이 줄어들어 전체 p99 지연이 개선됩니다.
실제 사용 사례
sched_ext는 이미 여러 대규모 환경에서 프로덕션 또는 실험적으로 사용되고 있습니다.
| 조직 | 사용 사례 | 스케줄러 | 효과 |
|---|---|---|---|
| Meta | 웹 서버 팜 | scx_rusty | NUMA 최적화로 p99 지연 15% 개선 |
| Meta | 스케줄러 실험 | 커스텀 | 프로덕션 A/B 테스트 시간 수주 → 수시간 |
| ghOSt 후속 | scx_rustland 기반 | 유저스페이스 스케줄링 연구 | |
| Valve | SteamOS / 게이밍 | scx_lavd | 프레임 일관성 향상, jitter 감소 |
| Arch Linux | CachyOS 배포판 | scx_lavd | 데스크탑 응답성 개선 |
| 학술 | 스케줄링 연구 | scx_central | 중앙집중 스케줄링 모델 실험 |
커널 설정 옵션
sched_ext를 활성화하기 위한 커널 설정 옵션들입니다.
| 설정 옵션 | 의존성 | 설명 |
|---|---|---|
CONFIG_SCHED_CLASS_EXT | CONFIG_BPF_SYSCALL, CONFIG_BPF_JIT | sched_ext 활성화 (핵심) |
CONFIG_BPF_SYSCALL | CONFIG_BPF | bpf() 시스템 콜(System Call) |
CONFIG_BPF_JIT | 아키텍처 지원 | BPF JIT 컴파일러 (성능) |
CONFIG_DEBUG_INFO_BTF | CONFIG_DEBUG_INFO | BTF 생성 (CO-RE 필수) |
CONFIG_BPF_LSM | 선택 | BPF LSM (보안 정책 연동) |
CONFIG_SCHED_DEBUG | 선택 | 스케줄러 디버깅 정보 노출 |
# 현재 커널에서 sched_ext 지원 여부 확인
grep CONFIG_SCHED_CLASS_EXT /boot/config-$(uname -r)
# BPF 관련 설정 일괄 확인
grep -E 'CONFIG_(SCHED_CLASS_EXT|BPF|BTF)' /boot/config-$(uname -r)
# CONFIG_SCHED_CLASS_EXT=y
# CONFIG_BPF=y
# CONFIG_BPF_SYSCALL=y
# CONFIG_BPF_JIT=y
# CONFIG_DEBUG_INFO_BTF=y
sudo apt install dwarves로 설치할 수 있습니다.
enqueue_task_scx 내부 구현
커널 코어의 enqueue_task_scx()는 태스크가 runnable 상태가 될 때 호출됩니다. 이 함수는 BPF ops의 select_cpu와 enqueue 콜백을 순차적으로 호출하며, 태스크 상태 머신을 관리합니다.
/* kernel/sched/ext.c — enqueue_task_scx 핵심 경로 (간략화) */
static void enqueue_task_scx(struct rq *rq,
struct task_struct *p, int flags)
{
struct sched_ext_entity *scx = &p->scx;
u64 enq_flags = 0;
/* 태스크 상태를 QUEUED로 전환 */
WARN_ON_ONCE(scx->flags & SCX_TASK_QUEUED);
scx->flags |= SCX_TASK_QUEUED;
/* wakeup인 경우 select_cpu 콜백 호출 */
if (flags & ENQUEUE_WAKEUP) {
s32 cpu;
if (SCX_HAS_OP(select_cpu)) {
cpu = SCX_CALL_OP_RET(SCX_KF_SELECT_CPU,
select_cpu, p,
task_cpu(p),
flags);
/* select_cpu에서 직접 DSQ에 삽입한 경우 */
if (scx->dsq)
return;
}
}
/* enqueue 콜백 호출 */
if (SCX_HAS_OP(enqueue)) {
SCX_CALL_OP(SCX_KF_ENQUEUE, enqueue, p, enq_flags);
} else {
/* enqueue 미구현 시 Global DSQ로 */
scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL,
SCX_SLICE_DFL, enq_flags);
}
}
pick_next_task_scx 내부
pick_next_task_scx()는 CPU에서 다음에 실행할 태스크를 선택합니다. Local DSQ를 먼저 확인하고, 비어있으면 dispatch 콜백을 호출합니다.
/* kernel/sched/ext.c — pick_next_task_scx (간략화) */
static struct task_struct *
pick_next_task_scx(struct rq *rq)
{
struct task_struct *p;
retry:
/* 1단계: Local DSQ에서 태스크 확인 */
p = first_local_task(rq);
if (p)
return p;
/* 2단계: dispatch 콜백 호출 (Custom/Global → Local) */
if (SCX_HAS_OP(dispatch)) {
SCX_CALL_OP(SCX_KF_DISPATCH, dispatch,
cpu_of(rq), rq->curr);
} else {
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
/* dispatch에서 Local DSQ에 태스크를 넣었을 수 있음 */
p = first_local_task(rq);
if (p)
return p;
/* 태스크 없음 — idle로 진입 */
return NULL;
}
/* scx_bpf_consume 내부 — DSQ 간 태스크 이동 */
bool scx_bpf_consume(u64 dsq_id)
{
struct scx_dispatch_q *dsq;
struct task_struct *p;
dsq = find_dsq_for_dispatch(this_rq(), dsq_id, 0);
if (!dsq)
return false;
/* FIFO 모드: 리스트 head에서 꺼냄 */
if (!list_empty(&dsq->list)) {
p = list_first_entry(&dsq->list,
struct task_struct,
scx.dsq_list);
move_task_to_local_dsq(p);
return true;
}
/* vtime 모드: RB-tree min에서 꺼냄 */
if (dsq->priq.rb_node) {
p = rb_entry(rb_first(&dsq->priq),
struct task_struct,
scx.dsq_priq);
move_task_to_local_dsq(p);
return true;
}
return false;
}
scx_bpf_dsq_insert()로 삽입한 태스크는 FIFO 리스트에, scx_bpf_dsq_insert_vtime()으로 삽입한 태스크는 RB-tree에 들어갑니다. 두 모드를 같은 DSQ에서 혼합 사용할 수 있습니다.
cgroup 지원
sched_ext는 cgroup v2와 통합하여 컨테이너 환경에서도 BPF 스케줄러를 사용할 수 있습니다. BPF 스케줄러는 태스크의 cgroup 정보를 조회하여 컨테이너별로 차별화된 스케줄링 정책을 적용할 수 있습니다.
/* cgroup-aware 스케줄링 예시 */
SEC("struct_ops/enqueue")
void BPF_PROG(cgroup_enqueue, struct task_struct *p,
u64 enq_flags)
{
struct cgroup *cgrp;
u64 cgrp_id;
u64 dsq_id;
/* 태스크의 cgroup ID 조회 */
cgrp = scx_bpf_task_cgroup(p);
if (cgrp) {
cgrp_id = cgrp->kn->id;
bpf_cgroup_release(cgrp);
} else {
cgrp_id = 0;
}
/* cgroup ID를 DSQ ID로 매핑 */
dsq_id = cgrp_id % NUM_DSQS;
/* cgroup 가중치 기반 vtime 삽입 */
scx_bpf_dsq_insert_vtime(p, dsq_id, SCX_SLICE_DFL,
calc_vtime(p), enq_flags);
}
/* scx_flatcg — cgroup 계층을 flat하게 처리 */
/*
* scx_flatcg는 cgroup 계층의 가중치를 단일 레벨로
* 평탄화(flatten)하여 WFQ를 수행합니다.
*
* 예: /A (weight=100) / /A/B (weight=50)
* → B의 실효 가중치 = 100 * 50 / sum = 계층 반영
*/
cgroup_init, cgroup_exit, cgroup_prep_move 등)을 사용하려면 SCX_OPS_HAS_CGROUP_WEIGHT 플래그를 설정해야 합니다. 이 플래그 없이 cgroup kfunc를 호출하면 verifier 에러가 발생합니다.
토폴로지 인식 스케줄링
현대 서버는 복잡한 CPU 토폴로지(SMT, LLC, NUMA)를 가집니다. sched_ext는 BPF 스케줄러가 토폴로지를 인식하여 최적의 CPU를 선택할 수 있도록 헬퍼를 제공합니다.
/* 토폴로지 인식 CPU 선택 */
SEC("struct_ops/select_cpu")
s32 BPF_PROG(topo_select_cpu, struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
bool is_idle = false;
s32 cpu;
/*
* scx_bpf_select_cpu_dfl()은 내부적으로 다음 순서로 탐색:
* 1. prev_cpu 자체가 idle인지 확인
* 2. prev_cpu와 같은 SMT 코어의 sibling idle 확인
* 3. prev_cpu와 같은 LLC의 다른 코어 idle 확인
* 4. 같은 NUMA 노드의 idle CPU 확인
* 5. 전체 시스템에서 idle CPU 확인
*/
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags,
&is_idle);
if (is_idle) {
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
SCX_SLICE_DFL, 0);
}
return cpu;
}
/* 커스텀 토폴로지 맵 (BPF 맵으로 구성) */
struct cpu_topo {
s32 llc_id; /* Last-Level Cache ID */
s32 numa_id; /* NUMA 노드 ID */
s32 core_id; /* 물리 코어 ID */
s32 smt_id; /* SMT 스레드 ID */
};
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1024);
__type(key, u32);
__type(value, struct cpu_topo);
} cpu_topology SEC(".maps");
rust/scx_utils 크레이트는 NUMA/LLC/코어 토폴로지 자동 감지, cpumask 관리, 로드 통계 수집 등의 유틸리티를 제공합니다. Rust 스케줄러를 작성할 때 이 라이브러리를 활용하면 토폴로지 관련 보일러플레이트를 크게 줄일 수 있습니다.
CPU 토폴로지 계층 구조
다음 다이어그램은 2 NUMA 노드, 노드당 2 LLC, LLC당 4 물리 코어, 코어당 2 SMT 스레드로 구성된 32-CPU 시스템의 토폴로지 계층을 보여줍니다. scx_bpf_select_cpu_dfl()은 이 계층을 아래에서 위로 순회하며, 캐시 지역성이 높은 CPU를 우선 선택합니다.
/* 토폴로지 기반 커스텀 CPU 선택 — LLC 친화성 최적화 */
/* LLC별 cpumask를 미리 계산 (init에서) */
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 16); /* 최대 16 LLC */
__type(key, u32);
__type(value, struct bpf_cpumask);
} llc_cpumasks SEC(".maps");
/* CPU → LLC ID 매핑 */
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1024);
__type(key, u32);
__type(value, u32);
} cpu_to_llc SEC(".maps");
SEC("struct_ops/select_cpu")
s32 BPF_PROG(topo_llc_select_cpu, struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
u32 *llc_id;
struct bpf_cpumask *llc_mask;
s32 cpu;
bool is_idle = false;
/* 1단계: prev_cpu 직접 확인 (최적 — 캐시 hit) */
if (scx_bpf_test_and_clear_cpu_idle(prev_cpu)) {
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
SCX_SLICE_DFL, 0);
return prev_cpu;
}
/* 2단계: prev_cpu의 LLC 내에서 idle CPU 탐색 */
llc_id = bpf_map_lookup_elem(&cpu_to_llc, &prev_cpu);
if (!llc_id)
goto fallback;
llc_mask = bpf_map_lookup_elem(&llc_cpumasks, llc_id);
if (!llc_mask)
goto fallback;
cpu = scx_bpf_pick_idle_cpu((const struct cpumask *)llc_mask,
0);
if (cpu >= 0) {
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
SCX_SLICE_DFL, 0);
return cpu;
}
fallback:
/* 3단계: 기본 탐색 (NUMA → 전체) */
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags,
&is_idle);
if (is_idle)
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
SCX_SLICE_DFL, 0);
return cpu;
}
/* init에서 토폴로지 맵 초기화 (유저스페이스 로더에서 수행) */
/*
* /sys/devices/system/cpu/cpuN/cache/indexN/shared_cpu_list
* /sys/devices/system/cpu/cpuN/topology/core_id
* /sys/devices/system/cpu/cpuN/topology/physical_package_id
* 에서 읽어 BPF 맵을 채움
*/
scx_bpf_select_cpu_dfl()은 기본적으로 idle한 전체 코어를 SMT sibling보다 우선 선택합니다. 스케줄러에서 SCX_OPS_HAS_IDLE_CPUS 플래그를 설정하면 이 최적화가 활성화됩니다.
향후 로드맵
sched_ext는 v6.12 머지 이후에도 활발하게 개발이 진행 중입니다. 주요 계획은 다음과 같습니다:
| 영역 | 계획 | 상태 |
|---|---|---|
| CPU 핫플러그 | 핫플러그 이벤트 처리 개선, CPU 추가/제거 시 DSQ 자동 재조정 | 진행 중 |
| cgroup v2 통합 | cgroup 계층에 따른 가중치 분배, 대역폭(Bandwidth) 제어 | 초기 |
| Nested scheduling | VM 내부의 sched_ext와 호스트 sched_ext 연동 | 계획 |
| Core scheduling | SMT 코어에서 보안 격리(Isolation)를 위한 동기 스케줄링 | 계획 |
| NUMA 자동 밸런싱 | 커널 내장 NUMA 밸런서와 sched_ext 연동 | 실험 중 |
| BPF arena | BPF ↔ 유저스페이스 공유 메모리 영역 | 진행 중 |
| sched_ext kfunc 확장 | 더 많은 커널 내부 정보 노출 (캐시 상태, 전력 등) | 계속 |
/* 향후 기대되는 kfunc 예시 (아직 미구현) */
/* CPU 캐시 상태 조회 */
u64 scx_bpf_cpu_cache_occupancy(s32 cpu) __ksym;
/* CPU 전력 상태 조회 */
u32 scx_bpf_cpu_freq_cur(s32 cpu) __ksym;
/* cgroup 가중치 조회 */
u32 scx_bpf_cgroup_weight(struct task_struct *p) __ksym;
공통 실수와 모범 사례
sched_ext 스케줄러를 개발할 때 자주 발생하는 실수와 이를 방지하는 모범 사례를 정리합니다. 아래 표의 문제들은 실제 개발 과정에서 빈번하게 보고된 사례입니다.
흔한 실수 테이블
| 문제 | 증상 | 원인 | 해결 |
|---|---|---|---|
| DSQ에 태스크를 삽입하지 않음 | 태스크 기아(Starvation), watchdog 타임아웃 | enqueue에서 일부 경로가 dsq_insert를 누락 | 모든 enqueue 경로에서 반드시 dsq_insert 호출 보장 |
| dispatch에서 consume 누락 | CPU idle 상태 지속, 낮은 처리량 | dispatch가 아무 태스크도 소비하지 않음 | 최소한 scx_bpf_consume(SCX_DSQ_GLOBAL) 폴백 추가 |
| 무한 슬라이스 남용 | 대화형 응답 지연, UI 프리징 | SCX_SLICE_INF를 모든 태스크에 적용 | 대화형 태스크에는 5~20ms 슬라이스 부여 |
| 과도한 kick_cpu 호출 | 높은 IPI 오버헤드, 처리량 저하 | 모든 enqueue에서 PREEMPT kick 발생 | 대화형 태스크 등 필요한 경우에만 사용 |
| BPF 맵 크기 부족 | 태스크 데이터 조회 실패, NULL 반환 | max_entries가 태스크 수보다 적음 | 충분한 크기 확보, TASK_STORAGE 맵 사용 |
| vtime 오버플로우 | 태스크 순서 역전, 불공정한 스케줄링 | u64 vtime이 매우 큰 값으로 누적 | 주기적으로 vtime 정규화(normalize) 수행 |
| NUMA 무시 | 원격(Remote) 메모리 접근 증가, 성능 저하 | 태스크를 다른 NUMA 노드 CPU에 배치 | LLC/NUMA 토폴로지 기반 CPU 선택 |
| init에서 에러 무시 | DSQ 미생성, 런타임 패닉 | create_dsq 실패 시 에러 코드 미확인 | init 반환값 검사, 실패 시 음수 반환 |
BPF verifier 에러 대처 체크리스트
| 단계 | 확인 항목 | 도구/명령 |
|---|---|---|
| 1 | verifier 로그에서 정확한 에러 위치 확인 | bpftool prog load -d <파일> /sys/fs/bpf/test type struct_ops |
| 2 | kfunc가 현재 콜백 컨텍스트에서 허용되는지 확인 | SCX_KF_* 마스크 테이블 참조 |
| 3 | sleepable 콜백 필요 여부 확인 | SEC("struct_ops") vs SEC("struct_ops.s") |
| 4 | 루프의 정적 상한 존재 여부 | bpf_loop() 사용 또는 상수 상한 |
| 5 | 스택 깊이 확인 (512B 이내) | 큰 구조체는 전역 변수/맵으로 이동 |
| 6 | NULL 포인터 체크 누락 여부 | 맵 조회 후 반드시 NULL 체크 |
성능 최적화 체크리스트
| 영역 | 최적화 방법 | 효과 |
|---|---|---|
| CPU 선택 | select_cpu에서 scx_bpf_select_cpu_dfl()로 idle CPU 우선 선택 | wake-up 지연 감소, 캐시 지역성 향상 |
| DSQ 배치 | Local DSQ 우선 사용, Global DSQ는 폴백 | lock 경합 감소, 처리량 향상 |
| 슬라이스 조정 | 대화형 3~5ms, 배치 20~50ms, 배경 100ms | 응답성과 처리량 균형 |
| 데이터 접근 | TASK_STORAGE 맵 사용 (해시 맵 대신) | O(1) 조회, 자동 정리 |
| 통계 수집 | per-CPU 변수로 통계 분리, 주기적 집계 | lock-free 업데이트 |
| NUMA 인식 | LLC/NUMA 토폴로지 기반 태스크 배치 | 원격 메모리 접근 감소 |
모범 사례 코드 패턴
/* 모범 사례 1: 안전한 enqueue — 모든 경로에서 dsq_insert 보장 */
SEC("struct_ops/enqueue")
void BPF_PROG(safe_enqueue, struct task_struct *p, u64 enq_flags)
{
struct task_ctx *tctx;
u64 dsq_id = SCX_DSQ_GLOBAL; /* 기본 폴백 */
u64 slice = SCX_SLICE_DFL;
tctx = bpf_task_storage_get(&task_data, p, 0, 0);
if (tctx) {
/* 커스텀 로직: DSQ 선택, 슬라이스 조정 */
dsq_id = select_dsq(tctx);
slice = calc_slice(tctx);
}
/* ✓ 어떤 경우든 반드시 dsq_insert 호출 */
scx_bpf_dsq_insert(p, dsq_id, slice, enq_flags);
}
/* 모범 사례 2: dispatch 폴백 체인 */
SEC("struct_ops/dispatch")
void BPF_PROG(safe_dispatch, s32 cpu, struct task_struct *prev)
{
/* 1순위: 커스텀 DSQ 소비 */
if (scx_bpf_consume(my_dsq_id))
return;
/* 2순위: Local DSQ (같은 CPU의 태스크) */
if (scx_bpf_consume(SCX_DSQ_LOCAL))
return;
/* 3순위: Global DSQ (최종 폴백) */
scx_bpf_consume(SCX_DSQ_GLOBAL);
}
/* 모범 사례 3: vtime 정규화로 오버플로우 방지 */
static u64 vtime_base; /* 전역 vtime 기준점 */
static u64 normalize_vtime(u64 vtime)
{
/*
* vtime이 기준점보다 너무 뒤처지면 기준점으로 당김
* → 장시간 sleep 후 복귀한 태스크의 과도한 우대 방지
*/
if (vtime_before(vtime, vtime_base - SCX_SLICE_DFL))
return vtime_base - SCX_SLICE_DFL;
return vtime;
}
/* 모범 사례 4: 안전한 init — 에러 처리 */
SEC("struct_ops.s/init")
s32 BPF_PROG(safe_init)
{
s32 ret;
int i;
/* DSQ 생성: 실패 시 에러 반환 → 안전한 폴백 */
for (i = 0; i < nr_domains; i++) {
ret = scx_bpf_create_dsq(i, -1);
if (ret) {
scx_bpf_error("DSQ %d 생성 실패: %d", i, ret);
return ret; /* CFS로 폴백 */
}
}
return 0;
}
/* 모범 사례 5: select_cpu에서 idle CPU 최적화 */
SEC("struct_ops/select_cpu")
s32 BPF_PROG(safe_select_cpu, struct task_struct *p,
s32 prev_cpu, u64 wake_flags)
{
bool is_idle = false;
s32 cpu;
/* 기본 idle CPU 선택 (커널 내장 로직 활용) */
cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
if (is_idle) {
/* idle CPU를 찾았으면 직접 Local DSQ에 삽입 (빠른 경로) */
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
SCX_SLICE_DFL, 0);
}
return cpu;
}
참고 자료
커널 공식 문서
- Extensible Scheduler Class (sched_ext) — 커널 공식 sched_ext 문서로, 아키텍처 개요와 사용법을 설명합니다
- BPF Documentation — BPF 서브시스템 공식 문서로, struct_ops 등 sched_ext의 기반 기술을 다룹니다
- Scheduler Documentation — 리눅스 스케줄러 전반의 공식 문서 인덱스입니다
LWN.net 기사
- The extensible scheduler class — sched_ext의 초기 설계 동기와 아키텍처를 소개하는 기사입니다
- Scheduler extensibility is headed for the mainline — sched_ext가 메인라인에 병합되기까지의 과정을 다룹니다
- BPF-based scheduling with sched_ext — BPF struct_ops 기반 스케줄러 확장의 기술적 세부사항을 분석합니다
- Pulling sched_ext into 6.12 — 리눅스 6.12에 sched_ext가 최종 병합된 과정을 설명합니다
GitHub 저장소
- sched-ext/scx — sched_ext 공식 사용자 공간 스케줄러 모음(scx_rusty, scx_lavd, scx_rustland 등)입니다
- scx_rusty — Rust 기반 NUMA 인식 스케줄러로, 부하 분산과 캐시 친화적 배치를 구현합니다
- scx_lavd — Latency-criticality Aware Virtual Deadline 스케줄러로, 대화형 워크로드에 최적화되어 있습니다
- scx_rustland — 사용자 공간에서 스케줄링 결정을 내리는 실험적 스케줄러입니다
커널 소스
kernel/sched/ext.c— sched_ext 코어 구현, DSQ 관리, BPF ops 디스패치 로직kernel/sched/ext.h— sched_ext 내부 헤더, 구조체 및 인라인 함수 정의include/linux/sched/ext.h— sched_ext 공개 헤더, task_struct 확장 필드tools/sched_ext/— sched_ext BPF 스케줄러 예제 및 테스트 도구kernel/sched/core.c— sched_class 등록 및 스케줄러 코어 통합 지점kernel/bpf/bpf_struct_ops.c— BPF struct_ops 프레임워크, sched_ext ops 등록 기반
발표 및 블로그
- sched_ext: scalable BPF CPU scheduler (LPC 2023) — Linux Plumbers Conference에서 발표된 sched_ext 설계 및 성능 분석입니다
- sched_ext: A BPF-Extensible Scheduler Class — sched_ext 핵심 개발자의 기술 블로그 시리즈로, 내부 동작을 상세히 설명합니다
관련 문서
sched_ext와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.