混合部署让延迟敏感型、计算密集型和离线任务同时运行在一台物理机上。不同 workload 对调度延迟、吞吐和资源隔离的要求不同,SMT 等共享硬件结构还会进一步带来资源竞争。
sched_ext 允许通过 BPF 实现调度策略。本文先梳理基本接口,再以 Rust 实现的 scx_lavd 为例,观察用户态前端怎样加载、控制和退出一个 BPF 调度器。
如果对 Linux 的任务唤醒、Runqueue、schedule()、上下文切换以及 DSQ 接入位置还不熟悉,可以先阅读《Linux CPU 调度与 sched_ext 基础》。
一、从 sched_ext 到 LAVD
混部场景主要存在四类需求:
- 延迟敏感任务需要缩短调度等待。
- 离线任务使用剩余算力,但不能干扰关键路径。
- SMT 共享资源,需要在吞吐和隔离之间权衡。
- FUSE、RPC、Futex 锁竞争等业务特征需要细粒度感知。
直接修改内核调度器意味着长期维护 patch。sched_ext 将策略迭代与核心调度框架分离,并利用 BPF verifier 和退出回退机制降低实验风险。
sched_ext 与 DSQ
用户态加载一组实现 struct sched_ext_ops 的 BPF 程序,由这些回调决定 CPU 选择、任务入队和 dispatch 等行为。
DSQ(Dispatch Queue)是核心调度器与 BPF 策略之间的队列抽象:
SCX_DSQ_GLOBAL:所有 CPU 共享的全局队列。SCX_DSQ_LOCAL:每个 CPU 的本地队列。- 自定义 DSQ:由 BPF 调度器按业务或优先级创建。
CPU 从本地 DSQ 运行任务;本地队列为空时尝试全局队列,再调用 ops.dispatch() 请求 BPF 调度器补充任务。
LAVD 的基本设计
LAVD 是 Latency-criticality Aware Virtual Deadline 的缩写,核心是评估任务的延迟关键性,并用虚拟截止期影响任务顺序。它还包括:
- 性能、均衡和节能模式
- 动态时间片与任务预占
- CPU preference 与 core compaction
- 同步唤醒和长任务 slice boost
- Futex holder boost
这些策略相互关联:core compaction 改变活跃 CPU 数量,CPU preference 决定核心使用顺序,而电源模式又同时影响两者。
二、Rust 前端参数
scx_lavd 使用 clap 将命令行参数解析到 Opts:
#[derive(Debug, Parser)]
struct Opts {
#[clap(long = "autopilot", action = clap::ArgAction::SetTrue)]
autopilot: bool,
#[clap(long = "slice-max-us", default_value = "5000")]
slice_max_us: u64,
#[clap(long = "no-futex-boost", action = clap::ArgAction::SetTrue)]
no_futex_boost: bool,
}
电源模式
| 参数 | 作用 |
|---|---|
--autopilot |
根据系统负载自动决定模式和 CPU 顺序 |
--autopower |
根据系统 power profile 切换模式 |
--performance |
性能优先 |
--balanced |
性能与能耗折中 |
--powersave |
节能优先 |
时间片与预占
--slice-min-us 和 --slice-max-us 限制动态时间片范围。--preempt-shift=N 使用 P = 0.5^N × 100% 限制能触发预占的延迟关键任务比例;默认 N=6 时约为前 1.56%。
功能开关
| 参数 | 作用 |
|---|---|
--no-futex-boost |
关闭 Futex holder boost |
--no-preemption |
关闭预占 |
--no-wake-sync |
关闭同步唤醒优化 |
--no-slice-boost |
关闭长任务时间片提升 |
--no-core-compaction |
不做核心压缩 |
--no-freq-scaling |
不控制 CPU 频率 |
这些开关适合做消融实验:保持 workload 不变,每次关闭一个优化,观察其对延迟和吞吐的独立影响。
当 core compaction 启用时,--cpu-pref-order 可以给出类似 0-3,7,6,5,4 的 CPU 使用顺序;显式指定该顺序也意味着不再由 Energy Model 生成顺序。--no-use-em 则只关闭 Energy Model 的参与。
三、从 main() 到 Scheduler::run()
main() 先解析参数。--version 输出构建版本,--help-stats 通过 SysStats::meta()、SchedSample::meta() 和 describe_meta() 输出统计字段说明。--monitor-sched-samples 只显示调度样本,--monitor 只运行统计监控,--stats 则在调度器运行的同时启动统计线程;-v/--verbose 控制包括 libbpf 在内的日志详细程度。正常模式还会通过 opts.proc() 检查并整理参数,再调用 Scheduler::init() 和 Scheduler::run():
let mut open_object = MaybeUninit::uninit();
loop {
let mut sched = Scheduler::init(&opts, &mut open_object)?;
if !sched.run(&opts, shutdown.clone())?.should_restart() {
break;
}
}
外层循环允许根据 UserExitInfo::should_restart() 重启调度器。Arc<AtomicBool> 是多线程共享的退出标志,Ctrl+C 处理器使用 Ordering::Relaxed 写入停止状态;这里只要求线程能够观察到退出请求,不依赖它与其他数据建立同步顺序。
Scheduler::run() 主循环
run() 并不在用户态逐个选择下一个任务,而是控制 BPF 调度器生命周期、更新配置并处理统计请求。
while !shutdown.load(Ordering::Relaxed) && !self.exited() {
if autopower {
(autopower, profile) = self.update_power_profile(profile);
}
match req_ch.recv_timeout(Duration::from_secs(1)) {
Ok(req) => {
let res = self.stats_req_to_res(&req)?;
res_ch.send(res)?;
}
Err(RecvTimeoutError::Timeout) => self.stop_monitoring(),
Err(e) => {
self.stop_monitoring();
Err(e)?
}
}
self.cleanup_introspec();
}
主循环负责更新 power profile、响应统计请求和清理 introspection 数据。退出后调用 rb_mgr.consume() 消费 Ring Buffer 中的剩余事件,取出 struct_ops Link 完成解绑,再由 uei_report!() 读取并返回内核记录的退出原因。
Drop 与 struct_ops 清理
impl Drop for Scheduler<'_> {
fn drop(&mut self) {
if let Some(struct_ops) = self.struct_ops.take() {
drop(struct_ops);
}
}
}
take() 将 Link 移出并把原字段设为 None,随后析构逻辑负责解绑,避免重复释放。
四、与 Futex 性能分析的联系及后续内容
--no-futex-boost 是天然的消融实验开关。《Futex 性能分析》整理了对应的观测指标,可以比较开启与关闭 Futex boost 后的锁等待 P50/P99/P999、延迟关键任务的调度等待、持锁线程获得 CPU 的速度,以及普通任务吞吐变化。
只有同时观察锁等待与调度结果,才能判断 holder boost 是缩短了关键路径,还是把开销转移给其他任务。
后续需要分析的 BPF 部分
当前主要覆盖 Rust 前端。要完整解释 LAVD,还需要继续阅读:
select_cpu如何选择 idle CPU。enqueue如何计算延迟关键性和虚拟截止期。dispatch如何在 DSQ 之间移动任务。- Futex holder boost 如何识别持锁线程。
- 调度样本怎样写入 Ring Buffer 并由用户态解析。