可扩展调度器类

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 并由 CFS 调度。

当 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 策略的任务由 CFS 调度。

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

# 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

如果设置了 CONFIG_SCHED_DEBUG,则可以如下确定给定任务是否在 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 和优先级队列运行。默认情况下,每个 CPU 有一个全局 FIFO (SCX_DSQ_GLOBAL) 和一个本地 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_DSQ_GLOBALSCX_DSQ_LOCAL 调用 scx_bpf_dsq_insert(),将任务立即插入到全局或本地 DSQ 中。

    • 通过使用小于 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()。诸如 SCX_DSQ_LOCALSCX_DSQ_GLOBAL 等内部 DSQ 不支持优先级队列调度,必须使用 scx_bpf_dsq_insert() 进行调度。有关更多信息,请参阅函数文档以及 tools/sched_ext/scx_simple.bpf.c 中的用法。

在哪里查找

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

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

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

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

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

ABI 不稳定性

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

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