可扩展调度器类

sched_ext 是一个调度器类,其行为可以由一组 BPF 程序定义 - BPF 调度器。

  • sched_ext 导出一个完整的调度接口,因此任何调度算法都可以在其上实现。

  • BPF 调度器可以根据自己的需要对 CPU 进行分组并一起调度它们,因为任务在唤醒时不会绑定到特定的 CPU。

  • BPF 调度器可以随时动态地开启和关闭。

  • 无论 BPF 调度器做什么,系统完整性都会得到维护。 每当检测到错误、可运行任务停滞或调用 SysRq 键序列 SysRq-S 时,都会恢复默认调度行为。

  • 当 BPF 调度器触发错误时,会转储调试信息以帮助调试。 调试转储传递给调度器二进制文件并由其打印出来。 调试转储也可以通过 sched_ext_dump 跟踪点访问。 SysRq 键序列 SysRq-D 触发调试转储。 这不会终止 BPF 调度器,并且只能通过跟踪点读取。

切换到和切换出 sched_ext

CONFIG_SCHED_CLASS_EXT 是启用 sched_ext 的配置选项,而 tools/sched_ext 包含示例调度器。 应启用以下配置选项才能使用 sched_ext

CONFIG_BPF=y
CONFIG_SCHED_CLASS_EXT=y
CONFIG_BPF_SYSCALL=y
CONFIG_BPF_JIT=y
CONFIG_DEBUG_INFO_BTF=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_PAHOLE_HAS_SPLIT_BTF=y
CONFIG_PAHOLE_HAS_BTF_TAG=y

仅当 BPF 调度器加载并运行时才使用 sched_ext。

如果任务显式将其调度策略设置为 SCHED_EXT,则在加载 BPF 调度器之前,它将被视为 SCHED_NORMAL 并由公平类调度器调度。

当 BPF 调度器加载并且 ops->flags 中未设置 SCX_OPS_SWITCH_PARTIAL 时,所有 SCHED_NORMALSCHED_BATCHSCHED_IDLESCHED_EXT 任务都由 sched_ext 调度。

但是,当 BPF 调度器加载并且 ops->flags 中设置了 SCX_OPS_SWITCH_PARTIAL 时,只有策略为 SCHED_EXT 的任务由 sched_ext 调度,而策略为 SCHED_NORMALSCHED_BATCHSCHED_IDLE 的任务由公平类调度器调度。

终止 sched_ext 调度器程序、触发 SysRq-S 或检测到任何内部错误(包括停滞的可运行任务)都会中止 BPF 调度器并将所有任务恢复为公平类调度器。

# make -j16 -C tools/sched_ext
# tools/sched_ext/build/bin/scx_simple
local=0 global=3
local=5 global=24
local=9 global=44
local=13 global=56
local=17 global=72
^CEXIT: BPF scheduler unregistered

BPF 调度器的当前状态可以如下确定

# cat /sys/kernel/sched_ext/state
enabled
# cat /sys/kernel/sched_ext/root/ops
simple

您可以通过检查此单调递增的计数器来检查自启动以来是否已加载任何 BPF 调度器(值为零表示尚未加载任何 BPF 调度器)

# cat /sys/kernel/sched_ext/enable_seq
1

tools/sched_ext/scx_show_state.py 是一个 drgn 脚本,可显示更详细的信息

# tools/sched_ext/scx_show_state.py
ops           : simple
enabled       : 1
switching_all : 1
switched_all  : 1
enable_state  : enabled (2)
bypass_depth  : 0
nr_rejected   : 0
enable_seq    : 1

给定任务是否在 sched_ext 上可以如下确定

# grep ext /proc/self/sched
ext.enabled                                  :                    1

基础知识

用户空间可以通过加载一组实现 struct sched_ext_ops 的 BPF 程序来实现任意 BPF 调度器。 唯一强制性字段是 ops.name,它必须是有效的 BPF 对象名称。 所有操作都是可选的。 以下修改后的摘录来自 tools/sched_ext/scx_simple.bpf.c,显示了一个最小的全局 FIFO 调度器。

/*
 * Decide which CPU a task should be migrated to before being
 * enqueued (either at wakeup, fork time, or exec time). If an
 * idle core is found by the default ops.select_cpu() implementation,
 * then insert the task directly into SCX_DSQ_LOCAL and skip the
 * ops.enqueue() callback.
 *
 * Note that this implementation has exactly the same behavior as the
 * default ops.select_cpu implementation. The behavior of the scheduler
 * would be exactly same if the implementation just didn't define the
 * simple_select_cpu() struct_ops prog.
 */
s32 BPF_STRUCT_OPS(simple_select_cpu, struct task_struct *p,
                   s32 prev_cpu, u64 wake_flags)
{
        s32 cpu;
        /* Need to initialize or the BPF verifier will reject the program */
        bool direct = false;

        cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &direct);

        if (direct)
                scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);

        return cpu;
}

/*
 * Do a direct insertion of a task to the global DSQ. This ops.enqueue()
 * callback will only be invoked if we failed to find a core to insert
 * into in ops.select_cpu() above.
 *
 * Note that this implementation has exactly the same behavior as the
 * default ops.enqueue implementation, which just dispatches the task
 * to SCX_DSQ_GLOBAL. The behavior of the scheduler would be exactly same
 * if the implementation just didn't define the simple_enqueue struct_ops
 * prog.
 */
void BPF_STRUCT_OPS(simple_enqueue, struct task_struct *p, u64 enq_flags)
{
        scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
}

s32 BPF_STRUCT_OPS_SLEEPABLE(simple_init)
{
        /*
         * By default, all SCHED_EXT, SCHED_OTHER, SCHED_IDLE, and
         * SCHED_BATCH tasks should use sched_ext.
         */
        return 0;
}

void BPF_STRUCT_OPS(simple_exit, struct scx_exit_info *ei)
{
        exit_type = ei->type;
}

SEC(".struct_ops")
struct sched_ext_ops simple_ops = {
        .select_cpu             = (void *)simple_select_cpu,
        .enqueue                = (void *)simple_enqueue,
        .init                   = (void *)simple_init,
        .exit                   = (void *)simple_exit,
        .name                   = "simple",
};

调度队列

为了匹配调度器核心和 BPF 调度器之间的阻抗,sched_ext 使用 DSQ(调度队列),它可以作为 FIFO 和优先级队列运行。 默认情况下,有一个全局 FIFO(SCX_DSQ_GLOBAL)和每个 CPU 一个本地 DSQ(SCX_DSQ_LOCAL)。 BPF 调度器可以使用 scx_bpf_create_dsq()scx_bpf_destroy_dsq() 管理任意数量的 DSQ。

CPU 始终执行来自其本地 DSQ 的任务。 任务被“插入”到 DSQ 中。 非本地 DSQ 中的任务被“移动”到目标 CPU 的本地 DSQ 中。

当 CPU 正在寻找要运行的下一个任务时,如果本地 DSQ 不为空,则选择第一个任务。 否则,CPU 尝试从全局 DSQ 移动任务。 如果这也没有产生可运行的任务,则调用 ops.dispatch()

调度周期

以下简要说明了如何调度和执行唤醒的任务。

  1. 当任务正在唤醒时,首先调用 ops.select_cpu() 操作。 这有两个目的。 首先,CPU 选择优化提示。 其次,唤醒空闲的选定 CPU。

    ops.select_cpu() 选择的 CPU 是一个优化提示,而不是绑定。 实际的决定是在调度的最后一步做出的。 但是,如果 ops.select_cpu() 返回的 CPU 与任务最终运行的 CPU 匹配,则可以获得一小部分性能提升。

    选择 CPU 的一个副作用是从空闲状态唤醒它。 虽然 BPF 调度器可以使用 scx_bpf_kick_cpu() 助手唤醒任何 CPU,但明智地使用 ops.select_cpu() 可以更简单、更有效。

    通过调用 scx_bpf_dsq_insert(),可以将任务立即从 ops.select_cpu() 插入到 DSQ 中。 如果任务从 ops.select_cpu() 插入到 SCX_DSQ_LOCAL 中,则它将插入到从 ops.select_cpu() 返回的任何 CPU 的本地 DSQ 中。 此外,直接从 ops.select_cpu() 插入将导致跳过 ops.enqueue() 回调。

    请注意,调度器核心将忽略无效的 CPU 选择,例如,如果它超出任务允许的 cpumask。

  2. 一旦选择了目标 CPU,就会调用 ops.enqueue()(除非任务直接从 ops.select_cpu() 插入)。 ops.enqueue() 可以做出以下决定之一

    • 通过调用 scx_bpf_dsq_insert() 并使用以下选项之一,立即将任务插入到全局或本地 DSQ 中:SCX_DSQ_GLOBALSCX_DSQ_LOCALSCX_DSQ_LOCAL_ON | cpu

    • 通过使用小于 2^63 的 DSQ ID 调用 scx_bpf_dsq_insert(),立即将任务插入到自定义 DSQ 中。

    • 将任务排队在 BPF 端。

  3. 当 CPU 准备好进行调度时,它首先查看其本地 DSQ。 如果为空,则它查看全局 DSQ。 如果仍然没有要运行的任务,则调用 ops.dispatch(),它可以使用以下两个函数来填充本地 DSQ。

    • scx_bpf_dsq_insert() 将任务插入到 DSQ。 可以使用任何目标 DSQ - SCX_DSQ_LOCALSCX_DSQ_LOCAL_ON | cpuSCX_DSQ_GLOBAL 或自定义 DSQ。 虽然目前无法在持有 BPF 锁的情况下调用 scx_bpf_dsq_insert(),但正在努力解决这个问题,并且将支持它。scx_bpf_dsq_insert() 调度插入,而不是立即执行它们。 最多可以有 ops.dispatch_max_batch 个挂起的任务。

    • scx_bpf_move_to_local() 将任务从指定的非本地 DSQ 移动到调度 DSQ。 无法在持有任何 BPF 锁的情况下调用此函数。 scx_bpf_move_to_local() 在尝试从指定的 DSQ 移动之前,会刷新挂起的插入任务。

  4. ops.dispatch() 返回后,如果本地 DSQ 中有任务,则 CPU 运行第一个任务。 如果为空,则采取以下步骤

    • 尝试从全局 DSQ 移动。 如果成功,则运行该任务。

    • 如果 ops.dispatch() 已调度任何任务,请重试 #3。

    • 如果上一个任务是 SCX 任务并且仍然可运行,则继续执行它(请参阅 SCX_OPS_ENQ_LAST)。

    • 进入空闲状态。

请注意,BPF 调度器始终可以选择立即在 ops.enqueue() 中调度任务,如上面的简单示例所示。 如果仅使用内置 DSQ,则无需实现 ops.dispatch(),因为任务永远不会在 BPF 调度器上排队,并且本地和全局 DSQ 都会自动执行。

scx_bpf_dsq_insert() 将任务插入到目标 DSQ 的 FIFO 中。 对于优先级队列,请使用 scx_bpf_dsq_insert_vtime()。 内部 DSQ(例如 SCX_DSQ_LOCALSCX_DSQ_GLOBAL)不支持优先级队列调度,并且必须使用 scx_bpf_dsq_insert() 进行调度。 有关更多信息,请参阅函数文档和 tools/sched_ext/scx_simple.bpf.c 中的用法。

任务生命周期

以下伪代码总结了由 sched_ext 调度器管理的任务的整个生命周期

ops.init_task();            /* A new task is created */
ops.enable();               /* Enable BPF scheduling for the task */

while (task in SCHED_EXT) {
    if (task can migrate)
        ops.select_cpu();   /* Called on wakeup (optimization) */

    ops.runnable();         /* Task becomes ready to run */

    while (task is runnable) {
        if (task is not in a DSQ) {
            ops.enqueue();  /* Task can be added to a DSQ */

            /* A CPU becomes available */

            ops.dispatch(); /* Task is moved to a local DSQ */
        }
        ops.running();      /* Task starts running on its assigned CPU */
        ops.tick();         /* Called every 1/HZ seconds */
        ops.stopping();     /* Task stops running (time slice expires or wait) */
    }

    ops.quiescent();        /* Task releases its assigned CPU (wait) */
}

ops.disable();              /* Disable BPF scheduling for the task */
ops.exit_task();            /* Task is destroyed */

在哪里查找

  • include/linux/sched/ext.h 定义了核心数据结构、ops 表和常量。

  • kernel/sched/ext.c 包含 sched_ext 核心实现和助手。 前缀为 scx_bpf_ 的函数可以从 BPF 调度器调用。

  • tools/sched_ext/ 托管示例 BPF 调度器实现。

    • scx_simple[.bpf].c:使用自定义 DSQ 的最小全局 FIFO 调度器示例。

    • scx_qmap[.bpf].c:支持使用 BPF_MAP_TYPE_QUEUE 实现的五个优先级级别的多级 FIFO 调度器。

ABI 不稳定性

sched_ext 为 BPF 调度器程序提供的 API 没有稳定性保证。 这包括 include/linux/sched/ext.h 中定义的 ops 表回调和常量,以及 kernel/sched/ext.c 中定义的 scx_bpf_ kfuncs。

虽然我们会尝试提供相对稳定的 API 表面,但在内核版本之间可能会发生更改,恕不另行通知。