59 min read

sched_ext/scx_rustland_core 조사 보고서

작성일: 2026-05-28
주제: Linux sched_ext가 CFS/EEVDF와 어떻게 붙어 있는지, scx.slice와 scheduler callback/tick/interrupt의 관계, runnable stall watchdog의 역할, task가 갖는 SCX 속성, 그리고 scx_rustland_core에서 scheduler가 “언제” “어떻게” 등장하는지.


0. 한 줄 요약

sched_ext는 CFS 안에 들어간 작은 hook이 아니라, Linux scheduler core에 등록되는 별도 scheduler class인 ext_sched_class다. BPF scheduler가 load되지 않았을 때 SCHED_EXT task는 fair-class, 즉 현재 mainline의 CFS/EEVDF 계열에서 SCHED_NORMAL처럼 처리된다. BPF scheduler가 load되면 mode에 따라 SCHED_NORMAL/SCHED_BATCH/SCHED_IDLE까지 모두 sched_ext로 가져오거나, SCX_OPS_SWITCH_PARTIAL mode에서 명시적 SCHED_EXT task만 가져온다. partial mode에서는 fair-class가 sched_ext보다 더 높은 sched_class precedence를 가지므로 normal/batch/idle task는 fair에서 먼저 선택된다.

p->scx.slice는 BPF scheduler가 task에게 준 runtime budget이다. 보통 scx_bpf_dsq_insert() 또는 scx_bpf_dsq_insert_vtime()의 slice 인자를 통해 설정되고, task가 실제로 실행되면 SCX core가 실행 시간만큼 자동 감소시킨다. 0이 되면 scheduling event가 유발된다. 다만 이것이 “BPF scheduler가 등장하는 유일한 기준”은 아니다. wakeup, fork/exec/policy change, affinity/nice/weight change, yield, sleep/block, CPU idle/kick, higher-priority scheduler class preemption, dispatch queue empty, watchdog/error/unload 등도 scheduler core와 SCX callback을 부른다.

“no runnable task stall이 따로 걸리지 않으면 이론상 scheduler interrupt가 아예 안 걸릴 수 있느냐”는 질문은 용어를 나눠야 한다. generic hardware interrupt나 Linux timer interrupt가 완전히 없어지는지와, BPF scheduler callback이 다시 호출되는지는 다른 문제다. SCX_SLICE_INF는 “infinite, implies nohz”로 정의되어 있고, runnable task가 하나뿐이거나 계속 keep-last되는 상황에서는 BPF scheduler callback이 매우 오래 또는 사실상 다시 호출되지 않을 수 있다. 반면 실제 시스템 전체 interrupt는 device interrupt, timer, RCU, perf, local timer, kernel config의 NO_HZ 여부에 따라 별개로 발생할 수 있다.

scx_rustland_core는 low-level sched_ext/BPF details를 Rust userspace scheduler에게 숨기는 framework다. BPF backend는 enqueue된 task의 pid, previous CPU, allowed CPU 수, enqueue flags, runtime, weight, vtime, comm 등을 ring buffer로 userspace에 전달하고, userspace scheduler는 pid/CPU/slice/vtime/flags를 다시 user ring buffer로 넘겨 BPF backend가 실제 DSQ insert와 CPU kick을 수행한다. 즉 scx_rustland_core에서 “scheduler”는 하나의 userspace process이며, kernel BPF side는 event transport, DSQ manipulation, safety fallback, timer/heartbeat, direct-dispatch fallback을 담당한다.


1. 배경: CFS, EEVDF, sched_class, 그리고 sched_ext

Linux scheduler를 이해할 때 가장 중요한 것은 “하나의 scheduler algorithm”이 아니라 “scheduler core + scheduler classes” 구조라는 점이다. 예전에는 user-facing 설명에서 CFS라고 부르던 fair_sched_class가 일반 task를 담당했다. Linux 6.6 이후 fair-class 내부의 pick logic은 EEVDF 기반으로 바뀌었지만, kernel scheduler class 이름과 policy mapping 관점에서는 여전히 fair class가 SCHED_NORMALSCHED_BATCHSCHED_IDLE의 기본 담당자라고 보면 된다. sched_ext는 이 fair class를 내부적으로 patch하는 것이 아니라, ext_sched_class라는 별도의 scheduler class를 추가한다.

공식 kernel 문서도 첫 문장에서 sched_ext를 “behavior can be defined by a set of BPF programs”인 scheduler class라고 설명한다. 또한 BPF scheduler는 runtime에 켜고 끌 수 있고, error, stalled runnable task, SysRq-S 등에서 기본 scheduling behavior로 복구된다고 명시한다.

Linux scheduler core는 각 CPU runqueue에서 다음 task를 고를 때 높은 priority의 class부터 차례로 후보를 묻는다. Stop/deadline/real-time/fair/ext/idle 같은 class ordering이 있고, 어느 class가 runnable task를 제공하면 lower class는 보통 보지 않는다. 여기서 sched_ext의 위치가 중요하다. SCX_OPS_SWITCH_PARTIAL이 설정된 mode에서는 SCHED_NORMALSCHED_BATCHSCHED_IDLE task는 fair-class에 남고, 명시적으로 SCHED_EXT policy를 가진 task만 sched_ext가 담당한다. 문서가 명시하듯 이때 fair-class는 SCHED_EXT보다 높은 sched_class precedence를 가진다.

따라서 “CFS에 어떻게 붙어 있나?”의 정확한 답은 다음과 같다.

  1. BPF scheduler가 load되지 않은 상태SCHED_EXT policy를 명시한 task도 fair-class에서 SCHED_NORMAL처럼 scheduling된다. 따라서 이 상태에서는 사실상 CFS/EEVDF path를 탄다.
  2. BPF scheduler가 load되고 SCX_OPS_SWITCH_PARTIAL이 clear된 상태: 일반 user task 대부분, 즉 SCHED_NORMALSCHED_BATCHSCHED_IDLESCHED_EXT가 sched_ext class로 이동한다. fair-class는 그 task들을 더 이상 담당하지 않는다.
  3. BPF scheduler가 load되고 SCX_OPS_SWITCH_PARTIAL이 set된 상태: 명시적 SCHED_EXT task만 sched_ext가 담당한다. normal/batch/idle task는 fair-class에 남고, fair-class precedence가 더 높으므로 같은 CPU에서 fair task가 runnable이면 SCHED_EXT task보다 먼저 고려된다.
  4. BPF scheduler 종료/error/stall: scheduler binary 종료, SysRq-S, internal error, stalled runnable task detection 등으로 BPF scheduler가 abort되면 모든 task는 fair-class로 되돌아간다.

kernel/sched/ext.c에는 DEFINE_SCHED_CLASS(ext)가 있고, 여기서 .enqueue_task.dequeue_task.yield_task.pick_task.put_prev_task.set_next_task.select_task_rq.task_tick.switched_to.prio_changed.update_curr 같은 scheduler class operations가 정의된다. 이것이 “붙어 있는” 실제 지점이다. sched_ext는 fair의 함수 pointer를 가로채는 것이 아니라 scheduler core가 class별 함수 pointer를 호출할 때 ext_sched_class가 자기 implementation을 제공한다.


2. Mental model: SCX의 핵심 객체들

SCX를 이해하는 가장 좋은 mental model은 네 층으로 나누는 것이다.

첫째, scheduler core는 기존 Linux scheduler framework다. 이 계층은 wakeup, enqueue/dequeue, context switch, timer tick, CPU hotplug, policy/affinity/nice change, yield, preemption 등 공통 event를 처리한다.

둘째, **ext_sched_class**는 scheduler core에 등록된 class다. core가 “다음 task를 골라라”, “task가 wakeup됐다”, “current task tick을 처리해라”라고 물으면 kernel/sched/ext.c의 SCX core 함수가 실행된다.

셋째, **BPF scheduler / struct sched_ext_ops**는 ext_sched_class가 필요할 때 호출하는 policy-defined callbacks다. 예를 들어 ops.select_cpuops.enqueueops.dispatchops.runningops.stoppingops.tickops.set_weightops.set_cpumaskops.init_taskops.exit 등이다. 모든 operation이 mandatory는 아니며, 최소로는 ops.name만 필요하고 나머지는 선택적으로 구현할 수 있다. 공식 문서의 minimal FIFO scheduler 예제도 select_cpuenqueueinitexitname 정도만 보여준다.

넷째, **DSQ(dispatch queue)**는 SCX가 scheduler core와 BPF scheduler 사이의 impedance mismatch를 해결하기 위해 쓰는 queue abstraction이다. CPU는 자기 local DSQ에서 task를 실행한다. built-in DSQ로는 global FIFO와 per-CPU local DSQ가 있고, BPF scheduler는 custom DSQ를 만들 수 있다. local DSQ가 비어 있으면 global DSQ를 먼저 consume하고, 그래도 없으면 ops.dispatch()를 호출해 BPF scheduler에게 task를 local DSQ로 옮기라고 요청한다.

이 네 층을 기준으로 보면 “scheduler가 등장한다”는 말은 여러 의미를 가진다.

  • scheduler core가 ext_sched_class method를 호출한다.
  • ext_sched_class method 안에서 BPF sched_ext_ops callback이 호출된다.
  • BPF callback이 DSQ에 task를 insert/move한다.
  • scx_rustland_core에서는 BPF callback이 userspace scheduler process를 깨우거나 ring buffer를 주고받는다.
  • CPU가 실제 context switch를 수행하면서 .set_next_task / ops.running path를 탄다.

이 보고서에서는 각각을 구분해 “언제/어떻게”를 설명한다.


3. Scheduling cycle: waking task가 실제 CPU에서 실행되기까지

공식 문서는 wakeup된 task가 scheduling되는 흐름을 간단히 설명한다. wakeup 시 ops.select_cpu()가 먼저 호출된다. 이 함수는 “task를 어느 CPU로 보내는 게 좋을지”에 대한 optimization hint이며, 동시에 idle CPU를 깨우는 side effect를 가진다. 선택된 CPU는 binding decision이 아니라 hint이므로, 나중에 실제 scheduling 시 task affinity나 runqueue 상황에 따라 다른 CPU에서 실행될 수 있다.

중요한 optimization이 있다. ops.select_cpu()에서 scx_bpf_dsq_insert() 또는 scx_bpf_dsq_insert_vtime()을 써서 task를 즉시 DSQ에 넣으면, 그 task에 대해 ops.enqueue() callback이 skip된다. 공식 문서는 direct insertion from ops.select_cpu()가 ops.enqueue() callback을 skip한다고 명시한다.

ops.select_cpu()에서 direct insert를 하지 않았다면 다음 단계로 ops.enqueue()가 호출된다. ops.enqueue()는 세 가지 일을 할 수 있다. 하나는 global/local built-in DSQ에 즉시 넣는 것, 둘째는 custom DSQ에 넣는 것, 셋째는 BPF side data structure에 task를 보관하는 것이다. custom DSQ나 BPF internal data structure에 들어간 task는 “BPF scheduler custody”에 들어간 것으로 간주되고, 나중에 dispatch되어 custody를 떠나거나 sleep/property change가 생기면 ops.dequeue()가 호출된다. 반면 built-in terminal DSQ에 바로 넣으면 BPF custody에 들어가지 않으므로 ops.dequeue()가 호출되지 않을 수 있다.

CPU가 다음 task를 고를 때는 local DSQ를 먼저 본다. local DSQ에 task가 있으면 그 task가 후보가 된다. local DSQ가 비어 있으면 global DSQ에서 local DSQ로 하나 move한다. global DSQ에서도 runnable task를 못 얻으면 ops.dispatch()를 호출한다. ops.dispatch()는 BPF scheduler에게 “지금 이 CPU가 local DSQ에 넣을 task가 필요하다”고 알리는 callback이다. BPF scheduler는 custom DSQ에서 task를 move하거나, BPF internal queue에서 task를 DSQ에 insert하거나, 아무것도 하지 않을 수 있다.

SCX core의 실제 dispatch path를 보면 local DSQ가 비어 있으면 먼저 global DSQ를 consume하고, ops.dispatch가 없거나 runqueue가 offline이면 no-task path로 간다. ops.dispatch가 있으면 SCX_CALL_OP(..., dispatch, cpu, prev)를 호출하고 dispatch buffer를 flush한다. dispatch 후에도 local DSQ가 비어 있으면 global DSQ를 다시 보고, BPF scheduler가 ineligible task만 계속 dispatch해서 loop에 빠지는 것을 막기 위해 periodic break/kick logic도 둔다.

만약 끝까지 다른 task를 못 찾았고 이전 task prev가 아직 runnable이면, 기본 동작은 prev를 계속 실행하는 것이다. 이때 SCX_EV_DISPATCH_KEEP_LAST event counter가 증가한다. 단, SCX_OPS_ENQ_LAST flag를 켰으면 slice가 끝났는데 다른 task가 없더라도 prev를 BPF scheduler의 ops.enqueue()로 넘기며, BPF scheduler가 follow-up scheduling event를 책임져야 한다. eBPF docs도 기본적으로 “다른 task가 없으면 slice가 만료되어도 current task를 계속 running”한다고 설명한다.

이 cycle을 “언제 scheduler가 등장하나”라는 관점으로 다시 쓰면 다음과 같다.

wakeup/fork/exec/migration/property change
  -> scheduler core
  -> ext_sched_class.select_task_rq_scx
  -> optional BPF ops.select_cpu
  -> optional direct DSQ insert; if direct insert, ops.enqueue skip
  -> ext_sched_class.enqueue_task_scx
  -> optional BPF ops.runnable
  -> BPF ops.enqueue
  -> task enters terminal DSQ, custom DSQ, or BPF queue

CPU needs next task
  -> scheduler core class traversal
  -> ext_sched_class.pick_task_scx / balance
  -> local DSQ?
  -> global DSQ?
  -> BPF ops.dispatch?
  -> local DSQ gets task
  -> context switch
  -> ext_sched_class.set_next_task_scx
  -> BPF ops.running

running
  -> timer tick / update_curr / explicit resched / yield / block / higher-class preemption
  -> optional BPF ops.tick
  -> slice depletion triggers resched
  -> ext_sched_class.put_prev_task_scx
  -> BPF ops.stopping
  -> if not runnable: BPF ops.quiescent

4. p->scx.slice의 의미: “scheduler 호출 타이머”가 아니라 runtime budget

p->scx.slice는 struct sched_ext_entity 안에 있는 BPF scheduler modifiable field다. kernel header comment는 이 field를 “Runtime budget in nsecs”라고 설명한다. 보통 scx_bpf_dsq_insert()로 설정되지만 BPF scheduler가 직접 수정할 수도 있고, task가 실행되면 SCX가 자동으로 감소시킨다. depletion 시 scheduling event가 trigger된다. preemption by %SCX_KICK_PREEMPT가 발생하면 이 값은 0으로 clear될 수 있으므로 “실제로 얼마나 실행했는지”를 계산하려면 p->se.sum_exec_runtime을 쓰라고 되어 있다.

SCX_SLICE_DFL은 20ms, SCX_SLICE_BYPASS는 5ms, SCX_SLICE_INF는 U64_MAX이고 “infinite, implies nohz”라고 정의되어 있다. SCX_SLICE_DFL은 BPF scheduler가 slice를 설정하지 않은 task가 선택될 때 default refill에 쓰인다.

실제 감소는 update_curr_scx()에서 일어난다. 이 함수는 current task의 delta execution time을 계산한 뒤 curr->scx.slice != SCX_SLICE_INF인 경우에만 min(slice, delta_exec)만큼 slice를 뺀다. slice가 0이 되면 core scheduling timestamp를 update한다.

periodic scheduler tick path인 task_tick_scx()는 먼저 update_curr_scx()를 호출한다. 그 다음 disabling/bypass 중이면 slice를 0으로 만들고, 그렇지 않고 BPF scheduler가 ops.tick을 구현했다면 ops.tick(curr)을 호출한다. 마지막에 curr->scx.slice가 0이면 resched_curr(rq)를 호출해 현재 CPU가 scheduling path로 들어가게 한다.

따라서 “값을 준 scx.slice를 기준으로 scheduler가 등장하는가?”의 답은 다음처럼 정밀하게 해야 한다.

  • 부분적으로 맞다. finite slice가 줄어 0이 되면 reschedule이 걸리고, CPU는 다음 scheduling cycle에 들어간다. 이때 local/global/custom DSQ 상황에 따라 ops.dispatch()가 호출될 수 있고, task가 멈추며 ops.stopping()이 호출될 수 있다.
  • 하지만 slice가 BPF scheduler callback의 유일한 trigger는 아니다. wakeup path는 select_cpu/enqueue, context switch-in path는 running, sleep/block path는 stopping/quiescent, yield path는 yield, property change path는 dequeue/set_weight/set_cpumask, CPU idle/hotplug path는 update_idle/cpu_acquire/cpu_release 등을 부른다. eBPF docs도 runnable과 enqueue는 related but not coupled이며, slice exhaustion 후에는 runnable 없이 enqueue가 발생할 수 있다고 명시한다.
  • slice가 0이 되어도 BPF scheduler가 반드시 새 task를 고르는 것은 아니다. 다른 task가 없으면 기본 SCX core는 prev를 keep-last로 계속 돌릴 수 있고, 필요하면 default slice refill도 한다. SCX_OPS_ENQ_LAST를 설정하면 이 behavior를 바꿔 BPF scheduler가 “last runnable task”까지 enqueue로 보게 만들 수 있다.
  • slice는 exact one-shot timer가 아니다. accounting은 update_curr_scx()가 호출될 때 이루어진다. periodic tick이 있으면 tick 단위로 줄고, 다른 scheduler entry에서도 update_curr 계열이 호출될 수 있다. 즉 “slice_ns가 5ms면 정확히 5ms 후 BPF scheduler function interrupt가 온다”는 식으로 보면 틀리다. 실제 preemption latency는 HZ, tick behavior, hrtick 사용 여부, NO_HZ, interrupt masking, kernel preemption model, CPU isolation 등 system configuration의 영향을 받는다.

5. ops.tick, timer tick, scheduler interrupt: 세 가지를 구분해야 한다

질문에서 말한 “scheduler interrupt”는 일반적으로 세 가지 의미로 섞여 쓰인다.

첫째는 hardware timer interrupt / scheduler tick이다. 이는 CPU local APIC timer 또는 tick device가 주기적으로 interrupt를 발생시켜 scheduler accounting, timekeeping, RCU, timers 등을 진행하는 mechanism이다. Linux에서는 NO_HZ idle/full 설정에 따라 periodic tick이 멈출 수 있다.

둘째는 scheduler core로 들어가는 reschedule event다. resched_curr(rq)TIF_NEED_RESCHEDscx_bpf_kick_cpu(), IPI, wakeup, yield, blocking syscall, interrupt return path 등으로 발생할 수 있다. 이것은 반드시 “timer interrupt”만 의미하지 않는다.

셋째는 BPF scheduler callback 호출이다. 예를 들어 ops.enqueueops.dispatchops.tickops.runningops.stopping이 호출되는 것을 scheduler가 “등장했다”고 부를 수 있다. 그러나 이것은 scheduler core entry와 1:1 대응하지 않는다. scheduling cycle에 들어갔지만 local DSQ에 task가 있어 BPF ops.dispatch를 부르지 않을 수도 있고, tick은 발생했지만 BPF scheduler가 ops.tick을 구현하지 않으면 BPF callback이 없다.

공식 eBPF docs에서 ops.tick은 “SCX task를 실행 중인 CPU에서 every 1/HZ seconds 호출되는 periodic tick”이고, p->scx.slice = 0으로 설정하면 즉시 dispatch cycle을 trigger한다고 설명한다.

하지만 task_tick_scx()를 보면 ops.tick은 선택적이다. SCX core는 update_curr_scx()로 slice를 갱신하고, SCX_HAS_OP(tick)일 때만 BPF tick을 호출한다. 그 다음 slice가 0이면 resched_curr()를 호출한다. 따라서 BPF scheduler가 ops.tick을 구현하지 않아도 slice accounting과 resched는 일어난다. 반대로 ops.tick이 있어도 slice가 충분히 남아 있고 다른 resched event가 없으면 context switch가 발생하지 않을 수 있다.


6. “no runnable task stall이 없으면 scheduler interrupt가 아예 안 걸릴 수 있나?”

이 질문의 핵심은 “SCX watchdog이 없으면 kernel이 BPF scheduler를 주기적으로 강제로 깨우는가?”와 “running task가 계속 CPU를 잡고 있으면 scheduling event가 없어질 수 있는가?”다.

6.1 runnable task stall watchdog은 무엇을 보장하나

sched_ext의 safety model은 BPF scheduler가 system을 영구적으로 망가뜨리지 못하게 하는 것이다. 문서에는 runnable task stall, internal error, SysRq-S 등에서 BPF scheduler가 abort되고 default scheduling behavior로 돌아간다고 되어 있다.

source를 보면 SCX watchdog은 delayed work로 각 online CPU의 runqueue timeout을 검사하고, watchdog check timestamp도 갱신한다. 또 scx_tick()은 watchdog timestamp가 너무 오래 갱신되지 않았으면 stall error로 scx_exit()을 호출한다. 즉 watchdog은 “BPF scheduler가 runnable task를 너무 오래 방치하거나 watchdog 자체가 돌지 못하는 상태”를 감지해 SCX scheduler를 내리는 safety net이다.

그러나 watchdog은 normal scheduling trigger 자체가 아니다. “watchdog이 없으면 scheduling이 안 된다”도 아니고, “watchdog이 있으면 모든 CPU가 주기적으로 BPF scheduler를 반드시 부른다”도 아니다. watchdog은 starvation/stall 감지와 rollback mechanism이다.

6.2 finite slice라면 보통 tick/accounting이 resched를 만든다

finite scx.slice를 가진 SCX task가 실행 중이면 update_curr_scx()가 execution delta만큼 slice를 감소시키고, task_tick_scx()는 slice가 0이면 resched_curr()를 부른다. 따라서 periodic tick이 active인 일반 상황에서는 slice depletion이 scheduling event를 만든다.

하지만 이 말은 “slice마다 정확히 BPF scheduler callback이 interrupt처럼 온다”는 뜻은 아니다. slice depletion 이후 scheduling path에 들어가도 local DSQ/global DSQ에 이미 task가 있으면 ops.dispatch()가 필요 없을 수 있고, 다른 task가 없으면 keep-last로 current task를 계속 실행할 수 있다.

6.3 infinite slice / NO_HZ / isolated CPU에서는 BPF scheduler가 다시 안 보일 수 있다

SCX_SLICE_INF는 “infinite, implies nohz”다. source comment 기준으로는 infinite slice가 tickless/nohz와 결합될 수 있음을 전제로 한다. 이런 경우 current SCX task가 계속 runnable이고, wakeup/yield/block/kick/affinity change/higher-priority task arrival 같은 event가 없다면, BPF scheduler callback이 다시 등장하지 않을 수 있다.

다만 “아예 interrupt가 없다”는 표현은 조심해야 한다. CPU에 hardware interrupt가 완전히 없다는 보장은 SCX만으로 할 수 없다. device interrupt, timer wheel, hrtimer, perf, RCU, scheduler clock, kernel housekeeping, NO_HZ_FULL configuration, CPU isolation, IRQ affinity 등이 모두 관여한다. SCX 관점에서 말할 수 있는 것은 “SCX/BPF scheduler callback이 slice expiration 때문에 주기적으로 강제 호출되지 않을 수 있다”는 것이다.

6.4 only runnable task에서는 keep-last가 scheduler callback을 줄인다

SCX_OPS_ENQ_LAST가 꺼져 있으면, CPU에서 더 이상 실행할 다른 SCX task를 못 찾았을 때 SCX core는 previous task를 계속 실행한다. source의 no-task path는 prev_on_rq이고 SCX_OPS_ENQ_LAST가 아니면 SCX_RQ_BAL_KEEP을 set하고 SCX_EV_DISPATCH_KEEP_LAST를 증가시킨다. 공식 docs도 SCX_EV_DISPATCH_KEEP_LAST를 “no other task was available because task continued running”으로 설명한다.

pick path에서는 keep-prev가 요청되었을 때 slice가 0이면 default slice를 refill하고 previous task를 계속 선택한다. 따라서 single runnable task workload에서는 BPF scheduler가 매 slice마다 반드시 개입하지 않는다. 오히려 기본 동작은 overhead를 줄이기 위해 current task를 유지하는 쪽이다.

SCX_OPS_ENQ_LAST를 켜면 behavior가 달라진다. eBPF docs는 이 flag가 set되면 “slice가 만료되었고 CPU에 다른 task가 없어도 그런 task를 ops.enqueue에 SCX_ENQ_LAST로 넘긴다”고 설명한다. 그러나 SCX_ENQ_LAST 문맥에서는 BPF scheduler가 follow-up event를 책임져야 하며, 그렇지 않으면 execution이 stall될 수 있다는 주의가 붙는다.

6.5 결론

이론적으로, 다음 조건들이 맞으면 SCX/BPF scheduler callback은 장시간 또는 사실상 다시 호출되지 않을 수 있다.

  • current task가 계속 runnable하다.
  • finite slice가 없거나, SCX_SLICE_INF/NO_HZ류 설정으로 periodic tick 기반 depletion이 없다.
  • wakeup, yield, block, signal, interrupt-return resched, CPU kick, higher-priority class enqueue, affinity/nice/weight change 같은 event가 없다.
  • 다른 runnable SCX task가 없어 keep-last path가 계속 유지된다.
  • watchdog/stall detection이 disabled되었거나 문제 상황을 abort하지 않는다.

하지만 “CPU에 interrupt가 물리적으로 하나도 안 걸린다”는 것은 SCX만으로 말할 수 없다. SCX scheduler가 안 보이는 것과 hardware interrupt-free execution은 다른 문제다. system-level로 interrupt-free를 논하려면 kernel config(CONFIG_NO_HZ_FULLCONFIG_RCU_NOCB_CPU), boot parameters(nohz_fullisolcpusrcu_nocbsirqaffinity), tick dependency, hrtimer, perf/NMI, device IRQ routing까지 봐야 한다.


7. Task lifecycle: callback 관점에서 “언제 scheduler가 등장하는가”

공식 문서는 SCX task lifecycle pseudo-code를 제공한다. 새 task가 만들어지면 ops.init_task()가 호출되고, SCX scheduling이 enable되면 ops.enable()이 호출된다. wakeup/migration path에서 ops.select_cpu()가 호출될 수 있고, task가 runnable해지면 ops.runnable()이 호출된다. task가 DSQ에 없거나 slice가 0이면 ops.enqueue()가 호출될 수 있다. CPU가 사용 가능해지면 ops.dispatch()가 task를 local DSQ로 move하고, task가 실제 실행되면 ops.running()이 호출된다. running 중에는 ops.tick()이 1/HZ마다 호출될 수 있고, slice가 0이면 dispatch cycle이 다시 일어난다. task가 멈추면 ops.stopping(), runnable하지 않게 되면 ops.quiescent()가 호출된다. 끝으로 SCX scheduling이 disable되면 ops.disable(), task destroy 시 ops.exit_task()가 호출된다.

이 pseudo-code는 핵심 흐름을 잡는 데 좋지만, 문서가 스스로 말하듯 모든 edge case를 포괄하지 않는다. direct-dispatch는 ops.enqueue()를 skip할 수 있고, property change는 dequeue/quiescent/set_weight/set_cpumask를 끼워 넣을 수 있다. higher-priority class가 runnable task를 enqueue하면 SCX pick이 abort되고 scheduler loop가 restart될 수 있다. source의 do_pick_task_scx()도 “higher-priority sched class가 runnable task를 enqueue했다면 retry”하는 logic을 둔다.

아래는 event별로 “등장하는 scheduler”를 더 세분화한 표다.

Eventscheduler core / ext_sched_classBPF sched_ext_opsscx_rustland_core에서는
BPF scheduler loadSCX enable, task attachops.init, ops.init_task, ops.enableBPF skeleton load, maps/ringbuf/timer/DSQ init
task wakeupselect_task_rq_scx, enqueue_task_scxselect_cpu, possibly runnable, enqueuedirect dispatch or ringbuf queued에 task info push
direct idle CPU selectedselect path에서 DSQ insertenqueue skip 가능built-in idle/direct dispatch가 켜져 있으면 kernel dispatch count 증가
CPU needs next taskpick_task_scx, balance/dispatch pathdispatch if local/global emptydispatched user ringbuf drain, usersched DSQ/per-CPU DSQ/shared DSQ consume
task starts runningset_next_task_scxrunningstart timestamp 기록, nr_running 증가
periodic ticktask_tick_scxoptional tickrustland core는 별도 ops.tick은 정의하지 않지만 slice/accounting은 SCX core가 처리
slice reaches 0resched_curr, later pick/balancemaybe dispatch, stopping, enqueuepending tasks 있으면 userspace scheduler process가 SCHED_DSQ에서 실행됨
task blocks/sleepsdequeue_task_scx, put_prevstopping, quiescent, maybe dequeuestop timestamp/runtime 누적
task yieldsyield_task_scxyield if providedrustland core source에서는 yield op는 등록하지 않음
nice/weight changereweight/prio change pathset_weight if providedqueued task info에는 p->scx.weight 포함
CPU affinity changeset_cpus_allowed_scxset_cpumask if provided, dequeue/requeue possibleuserspace 선택 CPU가 cpumask 밖이면 BPF가 previous CPU로 bounce
higher-priority class runnablescheduler class traversal restartSCX pick abortfair/RT/DL 등이 이김
watchdog/error/unloadSCX abort, fair fallbackexituserspace scheduler 종료 시 fair로 복귀

8. Task에는 어떤 SCX 속성들이 있는가

Linux의 실제 task_struct는 거대하고, BPF scheduler가 BTF/verifier 규칙 하에서 볼 수 있는 field는 kernel version에 따라 달라질 수 있다. 여기서는 SCX와 직접 관련 있는 task_struct::scx, 즉 struct sched_ext_entity 중심으로 정리한다.

struct sched_ext_entity는 task_struct 안에 embed되어 있고, “SCX에 의해 task를 scheduling하는 데 필요한 field”를 담는다. source comment가 그렇게 설명한다. 주요 field는 다음과 같다.

Field의미
schedcgroup scheduling이 켜져 있을 때 associated scx_sched pointer.
dsq현재 task가 들어가 있는 dispatch queue.
ops_stateBPF scheduler custody/queueing/dispatching 상태 추적. dequeue/dispatch race 방지에 중요.
ddsp_dsq_id, ddsp_enq_flagsdirect dispatch 관련 DSQ id와 enqueue flags.
dsq_listFIFO dispatch order list node.
dsq_priqdsq_vtime 기반 priority queue node.
dsq_seq, dsq_flagsDSQ 내 sequence/flags.
flagsSCX_TASK_QUEUED, SCX_TASK_IN_CUSTODY, state bits 등.
weightSCX task weight. eBPF docs의 set_weight range는 [1..10000].
sticky_cpu특정 CPU로 sticky하게 dispatch해야 하는 상황에서 사용.
holding_cpuCPU ownership/holding 상태 추적.
selected_cpuselect_cpu path에서 선택된 CPU hint.
kf_tasks[2]SCX_CALL_OP_TASK() 관련 internal task refs.
runnable_noderq->scx.runnable_list linkage.
runnable_atrunnable 상태가 된 시간. watchdog/starvation/accounting과 연결.
core_sched_atCONFIG_SCHED_CORE에서 core scheduling ordering에 사용.
sliceruntime budget in ns. BPF modifiable. 실행 중 자동 감소, depletion 시 scheduling event.
dsq_vtimevtime/deadline ordering. scx_bpf_dsq_insert_vtime()로 주로 설정.
disallowfuture sched_setscheduler(... SCHED_EXT ...) -EACCES로 거부하도록 설정 가능.
tasks_nodeSCX scheduler task list linkage.

flags에는 SCX_TASK_QUEUEDSCX_TASK_IN_CUSTODYSCX_TASK_RESET_RUNNABLE_ATSCX_TASK_DEQD_FOR_SLEEPSCX_TASK_IMMED 등이 있고, task state bits로 NONEINIT_BEGININITREADYENABLEDDEAD가 들어간다. reenqueue reason bits에는 NONEKFUNCIMMEDPREEMPTED가 있다.

DSQ 자체는 FIFO 또는 p->scx.dsq_vtime ordered priority queue로 동작할 수 있다. built-in DSQ는 항상 FIFO다. custom DSQ를 priority queue로 쓰려면 scx_bpf_dsq_insert_vtime()과 SCX_ENQ_DSQ_PRIQ 계열 semantics를 이해해야 한다. dsq_vtime을 task가 DSQ에 들어간 상태에서 수정하면 ordering이 망가질 수 있어 권장되지 않는다.

BPF callback level에서 task와 관련해 자주 보는 속성은 다음과 같다.

  • p->pidp->comm: 식별과 debug/logging.
  • p->cpus_ptrp->nr_cpus_allowed: CPU affinity constraints.
  • p->flags: kernel thread 여부, kswapd/kcompactd 여부 등 일부 scheduler가 special-case한다.
  • p->scx.weight: nice/cgroup weight mapping 후 SCX weight.
  • p->scx.slice: runtime budget.
  • p->scx.dsq_vtime: virtual time/deadline key.
  • scx_bpf_task_cpu(p): task의 associated CPU를 얻는 helper. stopping callback은 실제 running CPU가 아닌 CPU에서 호출될 수 있으므로 eBPF docs는 이 helper를 쓰라고 주의한다.

9. scx_rustland_core의 구조

scx_rustland_core는 “user-space schedulers running in user space”를 구현하기 위한 Rust framework다. docs.rs는 이 crate가 Linux sched_ext 기반 userspace scheduler 구현을 쉽게 하며, low-level kernel/BPF details를 다루지 않고 Rust API로 scheduler를 작성하게 해준다고 설명한다. 기능에는 generic BPF abstraction, task enqueue/dispatch, CPU selection, per-task time slice assignment, internal statistics reporting이 포함된다.

scx_rustland는 이 core를 기반으로 한 single user-defined scheduler이고, actual scheduling policy는 userspace Rust에 구현된다. scx_rustland README/docs는 interactive workload를 background CPU-intensive workload보다 우선하는 use case, gaming/video conferencing/live streaming 같은 low-latency interactive application을 예로 든다. production-critical scenario에서는 userspace offloading overhead 때문에 다른 scheduler가 더 나을 수 있지만, userspace libraries/tracing/external services와 통합할 가능성이 장점이라고 설명한다.

9.1 BPF backend가 하는 일

scx_rustland_core의 BPF source header는 매우 직접적이다. 이 BPF backend는 userspace counterpart를 위한 low-level sched_ext functionality를 구현하고, actual scheduling policy는 userspace가 구현한다. BPF part는 실행해야 할 task들의 total cputime과 weight를 수집해 userspace scheduler로 보내고, userspace scheduler는 task의 best order를 결정해 다시 BPF component에 dispatch list를 돌려준다. message passing에는 BPF_MAP_TYPE_RINGBUF와 BPF_MAP_TYPE_USER_RINGBUF가 쓰이며, queued는 BPF dispatcher가 userspace scheduler로 보내는 메시지, dispatched는 userspace scheduler가 BPF dispatcher로 보내는 메시지다.

BPF backend는 global shared DSQ, per-CPU DSQ, 그리고 userspace scheduler process를 위한 별도 SCHED_DSQ를 만든다. source comment는 userspace scheduler 자체가 별도 DSQ로 dispatch되며, 모든 다른 DSQ 이후 consume된다고 설명한다. 이것은 burst 방식으로 동작하기 위함이다. task들이 queued되고 userspace scheduler가 실행되어 dispatch하며, 그 task들의 time slice가 소진되면 scheduler가 다시 invoke되어 cycle을 반복한다.

이 design은 “scheduler가 언제 등장하는가”에 대한 rustland_core 특유의 답을 준다. 일반 SCX scheduler는 BPF ops.dispatch()에서 policy decision을 할 수 있다. rustland_core에서는 BPF ops.dispatch()가 userspace scheduler process를 SCHED_DSQ에서 실행하게 하고, userspace process가 queue를 drain해 policy decision을 한 뒤 dispatched user ring buffer에 결과를 넣는다. 그 결과를 다음 dispatch path에서 BPF가 drain해 실제 DSQ insert를 수행한다. source의 rustland_dispatch()는 dispatched user ringbuf를 drain하고, pending scheduling action이 있으면 SCHED_DSQ에서 userspace scheduler를 local DSQ로 move하고, per-CPU DSQ와 shared DSQ를 순서대로 consume한다.

9.2 userspace API: BpfScheduler

scx_rustland_core의 public API 중심은 BpfScheduler다. docs.rs는 BpfScheduler::init()이 BPF component를 register/initialize하고, dequeue_task()가 schedule되어야 하는 task를 가져오며, dispatch_task(&DispatchedTask)가 task를 특정 CPU로 dispatch하고, select_cpu(pid, prev_cpu, flags)가 idle CPU를 고르며, notify_complete(nr_pending)이 pending task 수를 BPF component에 알려준다고 설명한다.

dequeue_task()로 받는 QueuedTask에는 다음 field가 들어간다. docs.rs와 BPF source의 get_task_info()가 서로 대응된다.

struct QueuedTask {
    pid: i32,             // task id
    cpu: i32,             // previously used CPU
    nr_cpus_allowed: u64, // affinity상 사용 가능한 CPU 수
    flags: u64,           // enqueue flags
    start_ts: u64,        // last start timestamp
    stop_ts: u64,         // last stop timestamp
    exec_runtime: u64,    // last sleep 이후 accumulated CPU time
    weight: u64,          // SCX weight [1..10000], default 100 계열
    vtime: u64,           // scheduler가 쓰는 vtime/deadline key
    comm: [c_char; TASK_COMM_LEN],
}

dispatch_task()로 넘기는 DispatchedTask에는 pid, target cpuflagsslice_nsvtime이 들어간다. cpu가 RL_CPU_ANY면 BPF backend가 shared DSQ에 넣어 첫 available CPU가 실행하게 만들고, 특정 CPU면 cpu_to_dsq(cpu)로 per-CPU DSQ에 넣는다. slice_ns = 0은 default time slice 사용을 뜻한다고 docs.rs가 설명한다.

struct DispatchedTask {
    pid: i32,
    cpu: i32,       // RL_CPU_ANY 또는 target CPU
    flags: u64,
    slice_ns: u64,  // assigned time slice in ns; 0 = default
    vtime: u64,     // vruntime/deadline key
}

9.3 rustland enqueue path

rustland_enqueue()는 task가 ready-to-run이 되었을 때 호출된다. source comment는 “task p becomes ready to run; user-space scheduler가 필요하지 않으면 여기서 direct dispatch할 수 있고, 아니면 scheduler가 처리하도록 enqueue한다”고 설명한다.

special cases가 있다.

  • userspace scheduler task 자체라면 SCHED_DSQ에 insert한다. pending scheduling action이 있을 때 ops.dispatch()에서 이 DSQ가 consume된다.
  • per-CPU kthread, kswapdkhugepaged 같은 critical kernel thread는 userspace scheduler roundtrip을 거치지 않고 target CPU DSQ에 direct dispatch한다. source comment는 ksoftirqd/Nrcuop/N 같은 thread가 너무 오래 block되면 system 전체가 stall될 수 있기 때문이라고 설명한다.
  • builtin_idle이 켜진 wakeup이면 idle CPU를 찾아 direct dispatch할 기회를 준다. direct dispatch할 수 없거나 idle CPU가 없으면 userspace scheduler로 queue한다.
  • ring buffer가 full이면 scheduler congestion으로 보고 shared DSQ에 직접 dispatch한다. 즉 userspace scheduler가 밀릴 때도 task가 완전히 사라지지 않도록 kernel-side fallback이 있다.

9.4 rustland dispatch path

rustland_dispatch(cpu, prev)는 CPU local DSQ가 비어 있고 새 task가 필요할 때 호출된다. 먼저 userspace가 dispatched user ring buffer에 넣어둔 task들을 drain해 실제 BPF dispatch를 수행한다. 그 다음 pending scheduling action이 있고 SCHED_DSQ에서 userspace scheduler task를 local DSQ로 move할 수 있으면 return한다. 즉 CPU는 userspace scheduler process를 실행해 더 많은 scheduling decision을 만들게 된다. 그 다음 per-CPU DSQ, shared DSQ 순서로 task를 local DSQ로 move한다. 마지막으로 current task의 slice가 expire되었지만 다른 task가 없으면 slice를 slice_ns로 replenish하고 같은 CPU에서 한 round 더 돌게 한다.

이 구조 때문에 scx_rustland_core에서 scheduler의 등장은 두 단계로 보아야 한다.

  • kernel/BPF scheduler 등장ops.enqueueops.dispatchops.runningops.stopping 등 BPF callback이 실행된다.
  • userspace scheduler 등장: BPF backend가 SCHED_DSQ를 통해 scheduler process 자체를 runnable하게 만들고, CPU가 그 process를 실행한다. 그 userspace process는 ring buffer에서 QueuedTask를 받아 Rust data structure에 넣고, policy에 따라 DispatchedTask를 user ring buffer로 보낸다.

9.5 heartbeat timer

scx_rustland_core BPF backend에는 userspace scheduler heartbeat timer가 있다. source comment는 system이 완전히 idle하면 sched_ext watchdog이 이를 stall로 잘못 detect할 수 있으므로, timer로 scheduler를 periodic wake-up해 long inactivity를 피한다고 설명한다. timer callback은 userspace scheduler가 USERSCHED_TIMER_NS 이상 inactive였으면 usersched_needed flag를 set하고 scheduler task의 CPU를 SCX_KICK_IDLE로 kick한다.

이는 질문의 “no runnable task stall이 없으면 scheduler interrupt가 안 걸릴 수 있나”와 연결된다. rustland_core는 userspace scheduler process가 완전히 사라지는 것을 막기 위해 heartbeat를 둔다. 그러나 이것은 generic SCX semantics가 아니라 rustland_core implementation choice다. 다른 SCX scheduler는 이런 timer를 안 둘 수 있고, pure BPF scheduler라면 userspace scheduler process 자체도 없다.


10. scx_rustland_core에서 slice_ns와 userspace scheduler invocation

rustland_core는 BPF global slice_ns를 default task time slice로 가진다. rustland_enable()은 task가 sched_ext scheduler에 join할 때 p->scx.dsq_vtime = 0과 p->scx.slice = slice_ns로 초기화한다. dispatch 시 userspace가 DispatchedTask.slice_ns를 주면 BPF backend는 그 값을 scx_bpf_dsq_insert_vtime()에 넘긴다. 특정 CPU를 지정하지 않은 RL_CPU_ANY는 shared DSQ에, 특정 CPU는 per-CPU DSQ에 넣는다.

따라서 rustland_core에서 slice는 다음 cycle을 만든다.

  1. userspace scheduler가 task를 고른다.
  2. userspace scheduler가 DispatchedTask { pid, cpu, slice_ns, vtime, flags }를 user ring buffer에 넣는다.
  3. BPF rustland_dispatch()가 user ring buffer를 drain한다.
  4. BPF dispatch_task()가 해당 task를 shared/per-CPU DSQ에 slice_ns와 vtime으로 insert한다.
  5. CPU가 local DSQ로 move된 task를 실행한다.
  6. SCX core가 p->scx.slice를 runtime만큼 감소시킨다.
  7. slice가 0이 되면 resched가 걸린다.
  8. local/global/custom DSQ에 runnable task가 없고 pending work가 있으면 userspace scheduler task가 SCHED_DSQ에서 실행된다.
  9. userspace scheduler가 다시 queued tasks를 보고 dispatch한다.

하지만 이 cycle도 exact “slice interrupt”가 아니다. rustland_dispatch()는 pending tasks, per-CPU DSQ, shared DSQ, current task keep/replenish logic을 모두 본다. source는 “current task가 time slice를 expire했지만 아무도 run하고 싶어하지 않으면 slice를 replenish하고 same CPU에서 another round를 돌린다”고 명시한다.


11. Higher-priority class와 preemption

SCX task가 CPU에서 실행 중이라고 해서 모든 event를 SCX가 결정하는 것은 아니다. scheduler core class hierarchy에서 stop/deadline/RT/fair 등 higher-priority class가 runnable task를 제공하면 SCX보다 먼저 고려된다. partial mode에서는 fair가 SCX보다 높은 precedence를 가진다. source의 do_pick_task_scx()도 rq_modified_above(rq, &ext_sched_class)가 true면 RETRY_TASK를 반환해 scheduler loop를 restart한다.

SCX 내부 preemption에는 SCX_ENQ_PREEMPT와 SCX_KICK_PREEMPT 같은 mechanism이 있다. eBPF docs는 SCX_ENQ_PREEMPT를 local DSQ target으로 scx_bpf_dsq_insert()할 때 preemption을 trigger하는 flag로 설명하고, current task의 slice를 0으로 clear하고 CPU를 scheduling path로 kick한다고 한다. 즉 BPF scheduler가 “지금 들어온 task가 current를 선점해야 한다”고 판단하면 slice를 0으로 만드는 방식으로 reschedule을 유도할 수 있다.

SCX source comment도 ext class에서 wakeup_preempt는 NOOP라고 설명한다. SCX task는 wakeup 시점에 아직 CPU에 bind되어 있지 않기 때문에 traditional class의 wakeup_preempt처럼 동작하지 않고, preemption은 victim의 slice를 0으로 reset하고 reschedule을 trigger하는 방식으로 구현된다.


12. Debugging과 관찰 포인트

sched_ext의 상태는 /sys/kernel/sched_ext/state/sys/kernel/sched_ext/root/ops/sys/kernel/sched_ext/enable_seq 등으로 확인할 수 있다. 실행 중 scheduler별 events file은 SCX_EV_DISPATCH_KEEP_LASTSCX_EV_REFILL_SLICE_DFLSCX_EV_SELECT_CPU_FALLBACK 같은 diagnostic counter를 제공한다. 문서 예제는 /sys/kernel/sched_ext/<ops>/events의 format과 counter 의미를 설명한다.

특히 이 질문과 관련해 유용한 counter는 다음이다.

  • SCX_EV_DISPATCH_KEEP_LAST: 다른 task가 없어 current task를 계속 실행한 횟수. 이 값이 높으면 “slice가 끝나도 BPF scheduler가 매번 새 task를 고르지 않는” path가 많이 발생했다는 신호다.
  • SCX_EV_REFILL_SLICE_DFL: BPF scheduler가 slice를 주지 않았거나 keep-prev path 등에서 default slice refill이 발생한 횟수.
  • SCX_EV_SELECT_CPU_FALLBACK: BPF scheduler가 unusable CPU를 반환해 core scheduler가 fallback CPU를 조용히 선택한 횟수. affinity/cpumask 관련 bug를 찾는 데 유용하다.

개별 task가 sched_ext에 올라가 있는지는 /proc/<pid>/sched의 ext.enabled field를 보면 된다. 공식 문서도 grep ext /proc/self/sched 예시를 보여준다.

scx_rustland_core를 볼 때는 추가로 다음 통계가 중요하다. docs.rs는 nr_online_cpusnr_runningnr_queuednr_schedulednr_user_dispatchesnr_kernel_dispatchesnr_cancel_dispatchesnr_bounce_dispatchesnr_failed_dispatchesnr_sched_congested 같은 internal stats를 userspace scheduler policy에 활용할 수 있다고 설명한다.


13. 질문별 직접 답변

Q1. “CFS에 어떻게 붙어 있는지?”

CFS/EEVDF 내부 function에 BPF hook이 박힌 형태가 아니다. sched_ext는 별도 ext_sched_class다. scheduler core가 class별 operation table을 타고 들어가며, ext_sched_class에는 enqueue/dequeue/pick/tick/select_task_rq 등의 method가 등록되어 있다.

BPF scheduler가 load되지 않으면 SCHED_EXT task도 fair-class에서 SCHED_NORMAL처럼 처리된다. BPF scheduler가 load되고 full-switch mode면 normal/batch/idle/ext task가 SCX로 넘어온다. partial-switch mode면 명시적 SCHED_EXT task만 SCX로 오고 normal/batch/idle task는 fair-class에 남으며, fair-class가 더 높은 precedence를 갖는다.

Q2. “no runnable task stall이 따로 걸리지 않는다면 이론상 scheduler interrupt가 아예 안 걸릴 수도 있는지?”

SCX/BPF scheduler callback 기준으로는 “그럴 수 있다”가 맞다. 특히 SCX_SLICE_INF는 nohz를 imply하고, single runnable task + keep-last + no wakeup/yield/block/kick/property-change 상황에서는 BPF scheduler가 다시 등장할 이유가 없다.

하지만 hardware interrupt 기준으로 “아예 interrupt가 없다”고 말할 수는 없다. device IRQ, timer, RCU, perf/NMI, housekeeping CPU 설정, NO_HZ_FULL 여부 등 SCX 밖의 요소가 결정한다. watchdog은 runnable stall을 detect해 SCX를 abort하는 safety net이지 normal scheduling tick의 본질은 아니다.

Q3. “task에는 어떤 속성들이 있는지?”

SCX 관점의 핵심은 task_struct::scx의 sched_ext_entity다. 대표적으로 dsqops_state, direct-dispatch metadata, flagsweightsticky_cpuholding_cpuselected_cpurunnable_atcore_sched_atslicedsq_vtimedisallow가 있다. slice와 dsq_vtime은 BPF scheduler가 직접 수정 가능한 주요 policy field다.

scx_rustland_core userspace API에서는 QueuedTask로 pid, previous CPU, allowed CPU count, flags, start/stop timestamp, exec runtime, weight, vtime, comm을 받고, DispatchedTask로 pid, target CPU, flags, slice_ns, vtime을 돌려준다.

Q4. “값을 준 scx.slice를 기준으로 scheduler가 등장하는 것인지?”

slice는 중요한 trigger지만 유일한 trigger가 아니다. finite slice가 0이 되면 task_tick_scx()가 resched_curr()를 호출해 scheduling path로 들어가게 한다. 그러나 wakeup, enqueue, dispatch queue empty, sleep, yield, property change, CPU kick, higher-priority class enqueue 등도 scheduler를 등장시킨다.

또한 slice가 0이 되어도 다른 task가 없으면 default behavior는 previous task keep-last다. 이 경우 BPF scheduler가 매번 새 결정을 하지 않을 수 있다. SCX_OPS_ENQ_LAST를 켜면 last task도 enqueue로 넘기지만, follow-up event를 BPF scheduler가 책임져야 한다.

Q5. “특히 scheduler가 언제 어떻게 등장하는지?”

가장 압축하면 이렇다.

  • wakeup 때select_cpu가 먼저 등장하고, direct insert가 없으면 enqueue가 등장한다.
  • CPU local DSQ가 비었을 때: global DSQ를 보고, 그래도 없으면 dispatch가 등장한다.
  • context switch-in 때running이 등장한다.
  • tick 때: finite slice accounting이 일어나고, BPF scheduler가 tick을 구현했다면 ops.tick이 등장한다.
  • slice 0 때resched_curr로 scheduling cycle에 들어간다.
  • sleep/block/yield/property change 때stoppingquiescentdequeueyieldset_weightset_cpumask류가 등장한다.
  • higher-priority class가 runnable해질 때: SCX pick이 abort/retry되고 higher class가 먼저 등장한다.
  • scx_rustland_core에서는: BPF side가 task info를 ring buffer로 userspace scheduler에게 넘기고, pending work가 있으면 scheduler process 자체를 SCHED_DSQ로 dispatch해 userspace Rust scheduler가 등장한다. userspace scheduler는 DispatchedTask를 돌려주고 BPF가 실제 DSQ insert를 한다.

14. Practical implications for scheduler design

14.1 slice는 policy latency knob이지만 scheduler call frequency knob은 아니다

짧은 slice를 주면 CPU-bound task가 더 자주 reschedule path를 타게 만들 수 있다. 그러나 BPF scheduler가 매번 호출된다는 보장은 없다. local/global DSQ, keep-last, direct dispatch, ops.tick 구현 여부, HZ/NO_HZ, higher-priority class preemption이 모두 섞인다. 따라서 scheduler policy를 설계할 때 slice만으로 “내 Rust scheduler가 n ms마다 정확히 control을 회수한다”고 가정하면 안 된다.

14.2 userspace scheduler는 heartbeat와 fallback이 중요하다

scx_rustland_core는 userspace scheduler가 일반 process이므로, 그 process가 CPU를 얻어야 policy decision이 진행된다. 그래서 scheduler process용 SCHED_DSQ, heartbeat timer, kernel thread direct dispatch, ring buffer congestion fallback 같은 장치가 있다. 이것들은 userspace scheduling이 갖는 근본적 문제, 즉 “scheduler도 schedule되어야 한다”는 bootstrap 문제를 완화한다.

14.3 single runnable task와 tickless workload는 별도로 설계해야 한다

single runnable task workload에서 매 slice마다 fairness decision을 할 필요는 없다. 오히려 keep-last가 overhead를 줄인다. 그러나 latency measurement나 instrumentation을 위해 “매 5ms마다 userspace scheduler가 반드시 실행”되길 기대한다면 SCX_OPS_ENQ_LAST, heartbeat, explicit kick, finite slice, ops.tick 등을 조합해야 한다. 이때도 follow-up event를 잘못 설계하면 stall이 날 수 있다.

14.4 CFS와 coexistence mode를 명확히 정해야 한다

partial mode에서는 fair-class가 더 높은 precedence를 가지므로 SCHED_EXT task가 fair task와 같은 CPU에서 경쟁하면 기대와 다른 starvation/latency가 나올 수 있다. full switch mode에서는 normal/batch/idle task까지 SCX가 담당하므로 system 전체 behavior가 BPF scheduler policy에 크게 좌우된다. test 환경에서 “왜 내 SCX task가 안 돈다”는 문제는 partial/full mode와 task policy를 먼저 확인해야 한다.


15. Version/API caveats

sched_ext는 upstream kernel에 들어간 이후에도 빠르게 변하고 있고, sched_ext_ops ABI와 helper set은 kernel version에 따라 달라질 수 있다. scx_rustland_core source에도 kernel compatibility를 위한 fallback이 보인다. 예를 들어 scx_bpf_select_cpu_and()가 없는 kernel에서는 old API로 fallback한다고 되어 있으며, 이 fallback은 kernel <= 6.16 지원을 위해 필요하다고 comment되어 있다.

따라서 실제 kernel version, sched-ext/scx repo version, distro backport 여부가 다르면 field/callback/helper 이름이나 semantics가 일부 달라질 수 있다. 이 보고서의 큰 구조—ext_sched_class, DSQ, slice, tick/resched, full/partial switch, rustland_core ringbuf architecture—는 현재 문서와 source에서 확인되는 핵심이지만, production debugging에서는 반드시 해당 kernel tree의 include/linux/sched/ext.hkernel/sched/ext.c, 그리고 사용 중인 scx crate/source를 같이 확인해야 한다.


16. Source map

  • Kernel sched_ext documentation: sched_ext의 class 성격, dynamic enable/disable, full/partial switch, fair fallback, sysfs events, DSQ, scheduling cycle, lifecycle.
  • eBPF sched_ext_ops docs: tickrunnablerunningstoppingquiescentyieldset_weightset_cpumaskSCX_OPS_ENQ_LASTSCX_OPS_SWITCH_PARTIALSCX_ENQ_PREEMPT.
  • Linux source: ext_sched_class, enqueue/pick/tick/update_curr/watchdog/keep-last behavior, task scx fields.
  • scx_rustland_core docs/source: Rust API, BPF backend, ringbuf/user-ringbuf, DSQs, enqueue/dispatch/running/stopping/timer behavior.
  • scx_rustland docs: userspace Rust policy, interactive workload target, production overhead caveat.

17. 더 자세한 execution trace: “한 task가 깨어난 순간부터 다시 잠들 때까지”

이 section은 위에서 설명한 내용을 더 low-level trace 형태로 다시 풀어쓴 것이다. 실제 kernel function name과 BPF callback name을 섞어서 보면 “언제 scheduler가 등장하는가”가 더 명확해진다.

17.1 Wakeup 또는 fork/exec 이후 CPU 선택

어떤 sleeping task가 wakeup된다고 하자. Linux scheduler core는 해당 task를 runnable로 만들면서 target CPU를 정해야 한다. SCX task라면 ext_sched_class.select_task_rq에 해당하는 select_task_rq_scx() path가 들어간다. 이 path에서 BPF scheduler가 ops.select_cpu를 구현했다면 호출된다. 공식 문서는 ops.select_cpu()가 wakeup 시 첫 번째로 호출되는 operation이며, CPU selection hint와 idle CPU wakeup이라는 두 목적을 갖는다고 설명한다.

여기서 중요한 것은 “CPU selection”이 최종 binding이 아니라는 점이다. task의 cpumask, CPU hotplug, migration disabled 상태, race condition 때문에 ops.select_cpu()가 반환한 CPU와 실제 실행 CPU가 다를 수 있다. kernel docs는 CPU selection이 optimization hint이며, scheduler core가 invalid CPU selection을 ignore하고 fallback할 수 있다고 설명한다.

scx_rustland_core의 rustland_select_cpu()도 이 점을 반영한다. 먼저 prev_cpu가 task의 cpus_ptr 안에 있는지 확인하고, 아니라면 waker CPU나 cpumask의 first CPU로 바꾼다. builtin_idle이 꺼져 있으면 idle selection을 userspace policy에 완전히 위임하기 위해 previous CPU를 재사용한다. builtin_idle이 켜져 있으면 pick_idle_cpu()로 가까운 idle CPU를 찾고, direct dispatch가 안전하면 바로 per-CPU DSQ에 insert한다.

이 단계에서 direct dispatch가 성공하면 ops.enqueue()는 skip될 수 있다. 즉 wakeup event가 있었는데 BPF scheduler의 enqueue callback이 호출되지 않을 수 있다. 이는 많은 사람들이 처음 SCX를 볼 때 놓치는 포인트다. “wakeup된 task는 반드시 enqueue callback으로 간다”가 아니라, “select_cpu에서 terminal DSQ에 직접 넣으면 enqueue callback이 없다”가 정확하다.

17.2 Enqueue: runnable state transition과 custody

직접 DSQ insert가 없으면 enqueue_task_scx()가 호출된다. source를 보면 이 함수는 wakeup flag를 처리하고, sticky CPU를 정리하고, task가 이미 queued인지 확인한 뒤 set_task_runnable()SCX_TASK_QUEUEDrq->scx.nr_running++add_nr_running()을 수행한다. BPF scheduler가 runnable op를 구현했고 task가 migrating 중이 아니면 ops.runnable(p, enq_flags)를 호출한다. 이후 do_enqueue_task()가 실제 BPF ops.enqueue 또는 direct dispatch path로 이어진다.

여기서 runnable과 enqueue는 같은 말이 아니다. eBPF docs는 runnable이 task state transition notification이고, enqueue와 coupled되어 있지 않다고 설명한다. 예를 들어 remote CPU로 dispatch되는 경우 runnable 뒤에 enqueue가 안 올 수 있고, 반대로 slice exhaustion 뒤에는 runnable 없이 enqueue가 올 수 있다.

BPF scheduler가 task를 custom DSQ에 넣거나 BPF internal data structure에 저장하면 task는 BPF scheduler custody에 들어간다. 이후 그 task가 terminal DSQ로 dispatch되거나 sleep/property change로 custody를 떠나면 ops.dequeue()가 정확히 한 번 호출되어야 하는 semantics를 갖는다. built-in local/global DSQ에 바로 insert하는 terminal dispatch는 custody에 들어가지 않으므로 dequeue callback이 없을 수 있다.

scx_rustland_core에서는 userspace scheduler에게 보내기 위해 queue_task_to_userspace()가 queued ring buffer에 queued_task_ctx를 넣는다. ring buffer가 full이면 userspace scheduler가 congested하다고 보고 shared DSQ로 direct dispatch한다. 이 fallback은 userspace scheduler가 느려도 forward progress를 유지하려는 설계다.

17.3 CPU가 다음 task를 필요로 할 때

현재 CPU가 다음 task를 필요로 하는 경우는 많다. current task가 blocking syscall로 sleep할 수도 있고, voluntary yield할 수도 있고, finite slice가 0이 되었을 수도 있고, higher-priority class task가 도착했을 수도 있고, interrupt return path에서 need_resched가 set되어 있을 수도 있다. 이때 scheduler core는 class order를 따라 runnable task를 찾는다.

SCX까지 내려오면 pick_task_scx() / balance path가 실행된다. source는 먼저 local DSQ가 이미 있으면 바로 task가 있다고 판단한다. local DSQ가 비어 있으면 global DSQ를 consume한다. 그래도 없고 bypass mode가 아니며 ops.dispatch가 있으면 BPF ops.dispatch(cpu, prev)를 호출한다. dispatch는 BPF scheduler가 “지금 CPU의 local DSQ에 실행 가능한 task를 공급할 기회”다.

ops.dispatch가 task를 dispatch buffer에 넣으면 flush_dispatch_buf()가 이를 실제 local DSQ 또는 target DSQ로 반영한다. 그 후 previous task가 아직 runnable이고 slice가 남아 있으면 keep-prev path로 갈 수 있다. 아니면 local DSQ/global DSQ를 다시 본다. ops.dispatch()가 ineligible task만 계속 공급해 loop에 빠지는 것을 막기 위해 SCX core는 loop counter가 떨어지면 deferred kick을 걸고 break한다.

scx_rustland_core의 rustland_dispatch()는 이 일반 mechanism 위에서 userspace roundtrip을 한다. 먼저 userspace가 이미 결정해 놓은 dispatched task들을 user ring buffer에서 drain한다. 그 다음 userspace scheduler에게 pending work가 있으면 SCHED_DSQ에서 scheduler task를 local DSQ로 move한다. 그 후 per-CPU DSQ, shared DSQ 순서로 task를 local DSQ로 move한다. 마지막으로 아무 task도 없고 previous task가 still queued면 slice를 replenish한다.

즉 rustland_core에서 ops.dispatch는 “userspace scheduler의 decision을 적용하는 지점”이면서 동시에 “userspace scheduler process를 실행하게 만드는 지점”이다. policy decision이 BPF code 안에서 끝나는 scheduler와 다르게, rustland_core는 kernel/BPF callback과 userspace scheduler execution이 pipeline을 이룬다.

17.4 Context switch-in과 running/stopping

task가 실제로 CPU에서 실행되기 시작하면 ops.running(p) callback이 호출될 수 있다. eBPF docs는 running을 “task is starting to run on its associated CPU”라고 설명한다. scx_rustland_core의 rustland_running()은 userspace scheduler task라면 usersched_last_run_at timestamp만 갱신하고 return한다. 일반 task라면 debug message를 찍고 nr_running을 증가시키며 task-local storage의 start_ts를 현재 시간으로 기록한다.

task가 실행을 멈추면 ops.stopping(p, runnable)이 호출될 수 있다. eBPF docs는 stopping이 task execution stop event이며, runnable이 false이면 이후 quiescent가 호출된다고 설명한다. 이 callback은 task가 실제로 running한 CPU가 아닌 CPU에서 호출될 수 있으므로 scx_bpf_task_cpu(p)를 사용하라는 주의도 있다.

scx_rustland_core의 rustland_stopping()은 일반 task에 대해 nr_running을 감소시키고, stop_ts를 기록하며, exec_runtime += now - start_ts로 last sleep 이후 partial execution time을 누적한다. 이 값은 다음에 userspace scheduler가 QueuedTask.exec_runtime으로 받아 policy decision에 사용할 수 있다.


18. Scenario matrix: slice, runnable task 수, tick/nohz에 따른 behavior

아래 matrix는 질문의 핵심인 “slice 기준으로 scheduler가 등장하는가”와 “interrupt가 없을 수 있는가”를 workload별로 나눈 것이다. 실제 kernel behavior는 config와 version에 따라 달라질 수 있지만, SCX semantics 관점에서는 이 정도로 이해하면 된다.

Scenario A: finite slice, multiple runnable SCX tasks, periodic tick active

가장 교과서적인 상황이다. current task가 실행되고, tick마다 update_curr_scx()가 slice를 줄인다. slice가 0이 되면 task_tick_scx()가 resched_curr()를 호출한다. scheduler core가 다시 pick path에 들어가고, local/global/custom DSQ 상태에 따라 다음 task를 고른다. BPF scheduler가 ops.dispatch를 구현했고 DSQ가 비어 있다면 dispatch callback이 호출된다.

이 scenario에서는 slice가 scheduling granularity를 상당히 직접적으로 지배한다. 그러나 task가 이미 local DSQ에 줄 서 있으면 BPF ops.dispatch가 생략될 수 있고, BPF ops.tick은 구현된 경우에만 호출된다. 즉 scheduler core activity와 BPF policy callback activity를 구분해야 한다.

Scenario B: finite slice, only one runnable SCX task, default SCX_OPS_ENQ_LAST off

current task의 slice가 0이 되어 resched가 걸린다. 그러나 pick path에서 다른 task를 못 찾으면 SCX core는 previous task를 keep-last로 계속 실행한다. pick path는 slice가 0이면 default slice를 refill할 수 있다. 이 경우 SCX_EV_DISPATCH_KEEP_LAST와 SCX_EV_REFILL_SLICE_DFL 같은 counter가 증가할 수 있다.

이 scenario에서는 tick/resched는 있을 수 있지만 BPF scheduler가 매번 “새 task를 고르는” 형태로 등장하지 않는다. 이는 설계상 overhead를 줄이기 위한 behavior다. single runnable task에 대해 매 slice마다 userspace scheduler를 깨우는 것은 비용이 크고 의미가 적다.

Scenario C: finite slice, only one runnable SCX task, SCX_OPS_ENQ_LAST on

SCX_OPS_ENQ_LAST를 켜면 slice가 만료되고 다른 task가 없을 때도 current task가 SCX_ENQ_LAST로 ops.enqueue에 넘어간다. 이 mode는 scheduler가 single runnable task의 slice boundary까지 관찰하고 싶을 때 유용할 수 있다. 하지만 docs는 BPF scheduler가 follow-up event를 책임져야 하며 그렇지 않으면 execution이 stall될 수 있음을 경고한다.

이 scenario는 policy visibility를 높이는 대신 scheduler implementation 책임과 overhead를 늘린다. userspace scheduler라면 scheduler process wakeup latency도 추가된다.

Scenario D: SCX_SLICE_INF, current task CPU-bound, no external events

SCX_SLICE_INF는 update_curr_scx()에서 감소하지 않는다. header comment도 infinite slice가 nohz를 imply한다고 한다. 따라서 slice depletion은 발생하지 않는다. wakeup, block, yield, kick, higher-priority task arrival, signal handling, timer dependency 같은 외부 event가 없다면 SCX scheduler callback이 다시 등장하지 않을 수 있다.

이 scenario가 질문의 “scheduler interrupt가 아예 안 걸릴 수 있나”에 가장 가깝다. SCX/BPF scheduler perspective에서는 “그럴 수 있다”고 보는 것이 맞다. 다만 hardware interrupt perspective에서는 system configuration과 IRQ routing을 별도로 봐야 한다.

Scenario E: userspace scheduler architecture, scx_rustland_core, pending queued tasks 있음

task들이 BPF side에서 userspace scheduler로 queue되어 있고 nr_queued 또는 nr_scheduled가 남아 있다면 usersched_has_pending_tasks()가 true가 된다. rustland_dispatch()는 pending work가 있으면 SCHED_DSQ에서 userspace scheduler task를 local DSQ로 move하려고 한다. 이때 userspace scheduler process가 CPU를 얻어 Rust policy code를 실행한다.

이 scenario에서는 “scheduler가 등장한다”는 말이 진짜 process scheduling을 의미한다. scheduler는 BPF function이 아니라 Linux task다. 따라서 scheduler process 자체도 sched_ext policy와 DSQ mechanics의 대상이며, core는 이를 별도 DSQ로 다루어 bootstrap 문제를 해결한다.

Scenario F: userspace scheduler idle, heartbeat timer

rustland_core는 heartbeat timer를 둔다. source comment는 system이 완전히 idle하면 sched_ext watchdog이 잘못 stall로 detect할 수 있으므로 timer로 userspace scheduler를 periodic wakeup한다고 설명한다. timer callback은 scheduler가 오래 inactive하면 usersched_needed를 set하고 scheduler task CPU를 kick한다.

이 scenario는 generic SCX semantics와 rustland_core implementation을 구분해야 한다. 모든 SCX scheduler가 heartbeat를 가지는 것은 아니다. rustland_core는 userspace scheduler process를 유지해야 하므로 구현 차원에서 heartbeat를 둔 것이다.


19. SCX_ENQ_*와 SCX_OPS_*: scheduler가 직접 scheduling event를 만드는 knobs

SCX scheduler를 작성할 때 slice만큼 중요한 것이 enqueue flags와 ops flags다. 이들은 scheduler가 “언제 current를 선점할지”, “last task를 enqueue로 받을지”, “partial switch를 할지”, “migration disabled task를 직접 처리할지”를 결정한다.

SCX_OPS_SWITCH_PARTIAL은 CFS/fair와의 coexistence mode를 정한다. set이면 명시적 SCHED_EXT task만 SCX에 attach되고, clear이면 SCHED_NORMAL task도 포함된다. kernel docs는 partial mode에서 normal/batch/idle은 fair-class가 담당하며 fair가 SCX보다 높은 precedence라고 설명한다.

SCX_OPS_ENQ_LAST는 only-runnable-task handling을 바꾼다. 기본적으로 SCX core는 다른 task가 없으면 current task를 slice 만료 후에도 계속 실행한다. flag를 set하면 그런 task도 SCX_ENQ_LAST로 ops.enqueue에 넘긴다. 이 mode는 fine-grained accounting이나 custom latency policy에는 유용하지만, BPF scheduler가 next event를 보장하지 못하면 stall risk가 생긴다.

SCX_ENQ_PREEMPT는 BPF scheduler가 local DSQ에 task를 넣으면서 current를 선점하고 싶을 때 쓰는 flag다. docs는 이 flag가 current task의 slice를 zero로 clear하고 CPU를 scheduling path로 kick한다고 설명한다. 즉 “scheduler가 지금 당장 등장해야 한다”를 BPF side에서 강제하는 수단이다.

SCX_ENQ_CPU_SELECTED는 select_task_rq/select_cpu path가 호출되어 CPU가 선택되었음을 나타낸다. docs는 migration disabled 또는 single-CPU-affinity task는 decision이 없으므로 select_task_rq/select_cpu가 호출되지 않는다고 설명한다. 이것은 “모든 wakeup에 select_cpu가 온다”는 가정을 깨는 또 하나의 edge case다.


20. scx_rustland_core를 기준으로 한 pseudo-code

아래 pseudo-code는 scx_rustland_core의 BPF/backend와 userspace/frontend 관계를 단순화한 것이다. 실제 source와 동일한 code는 아니지만, control flow를 이해하기 위한 것이다.

// BPF side: wakeup/enqueue path
rustland_enqueue(task p, enq_flags) {
    if (p is userspace_scheduler) {
        insert(p, SCHED_DSQ, slice_ns);
        kick_if_needed();
        return;
    }

    if (p is critical per-cpu kthread or kswapd/khugepaged) {
        insert_vtime(p, per_cpu_dsq(prev_cpu), slice_ns, p->scx.dsq_vtime);
        kick(prev_cpu);
        return;
    }

    if (builtin_idle && wakeup && idle_cpu_found && can_direct_dispatch(cpu)) {
        insert_vtime(p, per_cpu_dsq(cpu), slice_ns, p->scx.dsq_vtime);
        kick(cpu);
        return;
    }

    if (queued_ringbuf_has_space()) {
        queued.push({ pid, prev_cpu, nr_cpus_allowed, flags,
                      start_ts, stop_ts, exec_runtime,
                      weight, vtime, comm });
        nr_queued++;
        kick_scheduler_if_needed();
    } else {
        insert_vtime(p, SHARED_DSQ, slice_ns, p->scx.dsq_vtime);
        nr_kernel_dispatches++;
    }
}
// userspace Rust side: policy loop, conceptually
loop {
    while let Some(q) = bpf.dequeue_task()? {
        runqueue.insert(q);  // policy-specific data structure
    }

    let mut dispatched = 0;
    while let Some(task) = policy_pick_next(&mut runqueue) {
        let cpu = maybe_select_cpu(task.pid, task.cpu, task.flags);
        let slice_ns = policy_assign_slice(task);
        let vtime = policy_update_vtime(task);
        bpf.dispatch_task(&DispatchedTask { pid, cpu, flags, slice_ns, vtime })?;
        dispatched += 1;
    }

    bpf.notify_complete(runqueue.len())?;
}
// BPF side: dispatch path
rustland_dispatch(cpu, prev) {
    drain(dispatched_user_ringbuf, dispatch_task);

    if (usersched_has_pending_tasks()) {
        if (move_to_local(SCHED_DSQ))
            return; // run userspace scheduler process
    }

    if (move_to_local(per_cpu_dsq(cpu)))
        return;

    if (move_to_local(SHARED_DSQ))
        return;

    if (prev && is_queued(prev) &&
        (!is_usersched_task(prev) || usersched_has_pending_tasks())) {
        prev->scx.slice = slice_ns;
    }
}

이 pseudo-code에서 볼 수 있듯 scx_rustland_core의 core invariant는 “userspace가 policy를 결정하지만, kernel/BPF가 final dispatch와 safety fallback을 책임진다”다. userspace가 너무 느리거나 ring buffer가 꽉 차도 BPF side가 direct dispatch fallback을 갖고, userspace가 고른 CPU가 affinity 밖이면 BPF side가 previous CPU로 bounce한다.


21. 실험 설계: 정말 “언제 등장하는지” 확인하는 방법

실제로 확인하려면 세 계층을 따로 instrument해야 한다.

첫째, SCX sysfs events를 본다. /sys/kernel/sched_ext/<ops>/events에서 SCX_EV_DISPATCH_KEEP_LASTSCX_EV_REFILL_SLICE_DFLSCX_EV_SELECT_CPU_FALLBACK 등을 본다. single runnable task에서 keep-last가 늘어나는지, zero-slice refill이 얼마나 발생하는지 확인할 수 있다.

둘째, BPF callback logging/counters를 둔다. ops.enqueueops.dispatchops.runningops.stoppingops.tick에 counter를 추가하면 scheduler callback이 실제로 몇 번 등장하는지 알 수 있다. 단, bpf_printk()는 overhead가 크고 scheduling behavior를 바꿀 수 있으므로 counter map 또는 ringbuf sample이 더 낫다.

셋째, userspace scheduler process activity를 본다. scx_rustland_core에서는 userspace scheduler가 실제 Linux task이므로 perf schedtrace-cmdftrace sched_switch/proc/<pid>/schednr_user_dispatchesnr_kernel_dispatchesnr_queuednr_scheduled를 같이 보면 된다. docs.rs가 제공하는 internal stats는 policy loop가 backlog를 얼마나 만들고 있는지 보는 데 유용하다.

추천 experiment는 다음과 같다.

  1. CPU 하나에 CPU-bound task 하나만 pin한다. finite slice_ns로 실행한다. SCX_EV_DISPATCH_KEEP_LAST와 SCX_EV_REFILL_SLICE_DFL이 증가하는지 본다.
  2. 같은 조건에서 SCX_SLICE_INF 또는 매우 큰 slice를 사용한다. BPF callbacks가 줄어드는지 확인한다.
  3. 두 개 이상의 CPU-bound task를 같은 CPU에 pin한다. finite slice에서 context switch와 dispatch가 늘어나는지 본다.
  4. interactive wakeup task를 추가한다. select_cpuenqueueSCX_ENQ_WAKEUP, CPU kick path가 어떻게 변하는지 본다.
  5. partial switch mode에서 fair task와 SCHED_EXT task를 같은 CPU에 둔다. fair precedence 때문에 SCHED_EXT task가 얼마나 밀리는지 확인한다.
  6. scx_rustland_core에서 ring buffer capacity 또는 scheduler process priority를 조절해 nr_sched_congested와 kernel direct dispatch fallback이 발생하는지 본다.

22. Common pitfalls

Pitfall 1: “SCX는 CFS hook이다”

아니다. ext_sched_class라는 별도 class다. full switch mode에서는 일반 task가 fair에서 SCX로 이동하므로 CFS/EEVDF와 나란히 동작하는 것이 아니라, normal task scheduling responsibility 자체가 SCX로 넘어간다. partial mode에서는 fair와 SCX가 coexist하지만 fair가 higher precedence다.

Pitfall 2: “wakeup은 항상 enqueue callback으로 온다”

아니다. select_cpu에서 direct DSQ insert를 하면 enqueue callback이 skip된다. 또한 single-CPU-affinity 또는 migration-disabled task에서는 select_cpu 자체가 skip될 수 있다.

Pitfall 3: “slice는 timer callback이다”

아니다. slice는 runtime budget이다. tick/update_curr path에서 감소하고 0이 되면 reschedule을 유도하지만, exact timer callback이 아니며, BPF ops.dispatch가 반드시 호출되는 것도 아니다.

Pitfall 4: “ops.tick이 없으면 slice가 안 줄어든다”

아니다. task_tick_scx()는 BPF ops.tick을 부르기 전에 update_curr_scx()로 slice를 줄인다. ops.tick은 optional BPF callback이다.

Pitfall 5: “userspace scheduler는 kernel scheduler보다 항상 느려서 toy다”

항상 그렇다고 단정할 수는 없다. scx_rustland docs는 userspace offloading overhead가 있으므로 performance-critical production에서는 다른 scheduler가 나을 수 있다고 인정하지만, userspace libraries/tracing/external services와 통합할 수 있는 장점도 있다고 설명한다. LPC 자료도 userspace scheduling이 overhead를 갖지만 development accessibility, observability, user-space libraries 접근성을 장점으로 제시한다.

Pitfall 6: “watchdog이 scheduling을 해준다”

watchdog은 scheduling policy가 아니다. runnable task stall이나 watchdog check failure를 detect해 scheduler를 abort하고 fair로 rollback하는 safety mechanism이다. normal dispatch는 DSQ, slice, wakeup, dispatch callback, CPU kick 등이 담당한다.


23. 연구/개발 관점에서의 해석

Operating systems 연구 관점에서 sched_ext의 재미있는 점은 scheduling policy의 일부를 kernel 밖으로 이동시키면서도, kernel scheduler core가 반드시 제공해야 하는 invariant를 유지한다는 것이다. 즉 task lifetime, CPU affinity, migration-disabled, kernel threads, RCU/softirq forward progress, watchdog rollback, cgroup integration, class precedence 같은 hard constraint는 kernel side가 쥐고 있고, policy decision—ordering, CPU selection heuristic, slice assignment, vtime/deadline update—은 BPF 또는 userspace로 확장된다.

scx_rustland_core는 이 분리를 더 밀어붙인다. BPF scheduler조차 policy를 최소화하고, userspace Rust scheduler가 decision을 한다. BPF side는 task event marshaling, DSQ insert/move, CPU kick, direct-dispatch fallback, heartbeat, safety를 맡는다. 이 구조는 Google ghOSt류의 userspace scheduling idea와 비슷한 motivation을 가진다. 다만 SCX는 BPF verifier와 kernel struct_ops를 활용해 Linux scheduler class 안에 들어온다는 점이 다르다. LPC 자료는 userspace scheduling의 장점으로 language/library 접근성, observability, external services integration을 들고, 단점으로 overhead와 fragmentation/user support를 든다.

질문의 핵심인 “언제 scheduler가 등장하는가”는 연구 설계에서도 중요하다. scheduler가 주기적으로 control을 얻는다고 가정하면 algorithm이 단순해지지만, 실제 SCX는 event-driven이다. wakeup과 dispatch queue empty가 더 중요한 trigger이고, slice는 runtime budget trigger일 뿐이다. single runnable task, nohz, keep-last, direct dispatch는 scheduler callback frequency를 크게 낮춘다. 따라서 SCX scheduler를 설계할 때는 다음을 먼저 정해야 한다.

  • 모든 runnable task를 scheduler가 매번 관찰해야 하는가, 아니면 DSQ에 위임해도 되는가?
  • single runnable task의 slice boundary를 관찰해야 하는가?
  • interactive wakeup preemption은 SCX_ENQ_PREEMPT로 강제할 것인가, 아니면 next slice까지 기다릴 것인가?
  • userspace scheduler process가 CPU를 못 얻는 bootstrap 문제를 어떻게 피할 것인가?
  • watchdog timeout 안에 모든 runnable task가 실행될 수 있도록 어떤 fallback을 둘 것인가?
  • partial mode에서 fair-class precedence를 의도적으로 사용할 것인가, 아니면 full switch로 system 전체를 장악할 것인가?

24. 최종 mental checklist

SCX 관련 문제를 볼 때 다음 순서로 질문하면 대체로 길을 잃지 않는다.

  1. 이 task가 정말 SCX task인가? /proc/<pid>/sched의 ext.enabled를 확인한다. BPF scheduler가 load되지 않았으면 SCHED_EXT도 fair에서 돈다.
  2. full switch인가 partial switch인가? SCX_OPS_SWITCH_PARTIAL 여부에 따라 fair와 SCX의 관계가 완전히 달라진다.
  3. wakeup path에서 direct dispatch가 되었는가? direct insert면 ops.enqueue가 안 보일 수 있다.
  4. task가 BPF custody에 들어갔는가? custom DSQ/BPF queue에 들어간 task만 custody/dequeue semantics가 적용된다. terminal DSQ direct dispatch는 다르다.
  5. CPU local DSQ/global DSQ가 비었는가? 비어 있지 않으면 ops.dispatch가 안 올 수 있다.
  6. current task의 slice가 finite인가 infinite인가? finite면 accounting/depletion/resched path가 있고, infinite면 nohz/long-running behavior를 고려해야 한다.
  7. only runnable task인가? only runnable이면 keep-last가 scheduler callback을 줄인다. SCX_OPS_ENQ_LAST 여부를 본다.
  8. higher-priority class가 runnable한가? fair/RT/DL task가 SCX보다 먼저 선택될 수 있다. partial mode에서는 특히 fair가 중요하다.
  9. userspace scheduler가 involved되어 있는가? scx_rustland_core라면 BPF callback 횟수와 userspace process scheduling 횟수는 다르다. ringbuf, SCHED_DSQ, heartbeat, stats를 따로 봐야 한다.
  10. watchdog/fallback이 발생했는가? scheduler가 abort되면 fair로 돌아가므로, 이후 관찰은 SCX behavior가 아닐 수 있다.

이 checklist를 적용하면 “왜 내 scheduler가 안 불리지?”, “왜 slice를 줬는데 userspace scheduler가 안 깨어나지?”, “왜 CFS task가 SCX task보다 먼저 도는 것 같지?”, “왜 single task가 계속 같은 CPU에서 도는 것 같지?” 같은 질문을 훨씬 빠르게 분해할 수 있다.