任务调度基础:调度系统的核心目标与工作流程

各位同学,今天我们来聊聊调度系统最底层的那些事。说实话,我见过不少团队一上来就搞复杂调度策略,结果连最基本的吞吐量都上不去。这就像盖楼不打地基,迟早要出问题。

调度系统说白了就干三件事:谁先跑、跑多久、跑在哪。但就是这三件事,背后藏着不少门道。我当年在阿里做离线训练集群时,就因为调度策略没想清楚,导致整个集群利用率不到30%。嗯,那教训够我记一辈子。

一、调度系统的三大核心目标

这三个目标就像不可能三角,你很难同时做到极致。我个人习惯是,先搞清楚业务场景,再决定优先保哪个。

1. 吞吐量(Throughput)

吞吐量就是单位时间内能跑完多少任务。说白了,就是集群的「出活效率」。

核心公式:吞吐量 = 完成任务数 / 时间单位

举个例子:你的集群一天能跑完100个训练任务,那就是100 tasks/day。

我在项目中遇到过一种情况:为了追求吞吐量,把所有小任务都塞到同一个GPU上跑。结果呢?显存爆了,任务全挂。吞吐量直接归零。

影响吞吐量的关键因素:

  • 任务粒度:任务太大,排队时间长;任务太小,调度开销占比高
  • 资源碎片:GPU显存、CPU核数、内存,任何一个维度碎片化都会拖慢吞吐
  • 调度延迟:每次调度决策的时间,积少成多就是大问题

我的经验:吞吐量不是越高越好。我曾经把调度间隔从1秒降到100毫秒,吞吐量确实上去了,但调度器CPU占用率飙到了80%。得不偿失。找到那个平衡点才是关键。

2. 公平性(Fairness)

公平性是个很有意思的话题。你想想看,如果A团队提交了100个任务,B团队只提交了1个任务,该让谁先跑?

公平性不是简单的「一人一个」,而是要考虑资源权重、任务优先级、历史使用量等因素。我见过最典型的公平性问题,就是某个大团队把集群占满了,小团队的任务永远排不上队。

常见的公平性模型:

模型 原理 适用场景
Max-Min Fairness 先满足最小需求,再均分剩余资源 多租户共享集群
Dominant Resource Fairness 按主导资源维度进行公平分配 异构资源场景
Weighted Fairness 按权重比例分配资源 有明确优先级需求的场景

避坑指南:我曾经在DRF实现上踩过坑。当时只考虑了GPU显存作为主导资源,忽略了CPU和内存。结果某个任务虽然显存需求小,但CPU需求巨大,导致其他任务被饿死。记住:主导资源一定要选对维度。

3. 资源利用率(Utilization)

资源利用率,说白了就是你的GPU有没有在干活。我见过太多集群,GPU利用率不到50%,但大家还在抱怨任务排不上队。为什么?因为资源碎片化严重。

资源利用率的计算方式:

资源利用率 = 实际使用资源 / 总可用资源 × 100%

// 举个例子
// 集群有10张A100,每张80GB显存
// 当前使用了6张卡,每张用了60GB
// GPU利用率 = (6 × 60) / (10 × 80) = 45%

这里有个坑:利用率高不等于效率高。我曾经把GPU利用率推到95%,结果发现大部分时间都在做数据加载,真正的计算时间不到30%。这就是典型的「伪利用率」。

三个目标的关系:

  • 吞吐量和利用率正相关,但不成线性
  • 公平性往往会牺牲吞吐量(因为要等慢的任务)
  • 过度追求利用率会导致公平性下降

二、调度器的基本工作流程

调度器的工作流程,其实就四个步骤。我习惯用「排队-选人-分配-执行」来记忆。

步骤1:任务入队(Enqueue)

用户提交任务后,调度器先把任务放到等待队列里。这个队列不是简单的FIFO,而是按优先级、资源需求、提交时间等多个维度排序。

// 伪代码:任务入队逻辑
function enqueue(task):
    // 检查任务合法性
    if not validate(task):
        return "任务参数不合法"
    
    // 计算任务优先级
    task.priority = calculate_priority(task)
    
    // 插入到等待队列(按优先级排序)
    wait_queue.insert(task)
    
    // 触发调度
    schedule()

我的习惯:入队时我会额外记录任务的「资源画像」,包括显存峰值、CPU使用模式、IO密集程度。这些信息在后续调度决策中非常有用。

步骤2:资源匹配(Match)

这一步是调度器的核心。调度器需要从空闲资源池里,找到能跑这个任务的节点。匹配条件包括:

  • 显存容量:必须大于任务需求
  • CPU核数:满足最小要求
  • 网络带宽:分布式训练需要高带宽
  • 亲和性:尽量让任务跑在已有数据的节点上

我曾经遇到过一个经典问题:某个任务需要8张GPU,但集群里只有3个节点各有4张空闲卡。调度器死活匹配不上,因为默认只做单节点匹配。后来我加了「跨节点聚合」功能,才解决这个问题。

步骤3:资源分配(Allocate)

匹配成功后,调度器开始分配资源。这里有个关键点:分配不是一次性完成的。

// 资源分配策略示例
function allocate(task, node):
    // 预留资源(防止被其他任务抢走)
    reserve(node, task.resources)
    
    // 创建容器/进程
    container = create_container(task, node)
    
    // 绑定资源
    bind_gpu(container, task.gpu_ids)
    bind_cpu(container, task.cpu_cores)
    bind_memory(container, task.memory)
    
    // 启动任务
    start_task(container)
    
    // 更新资源状态
    update_resource_state(node, -task.resources)

注意:分配资源时一定要做「资源隔离」。我见过因为没做显存隔离,两个任务互相抢占显存,最后双双OOM挂掉的惨案。

步骤4:状态监控(Monitor)

任务跑起来之后,调度器的工作还没结束。需要持续监控:

  • 资源使用情况:是否超限?是否浪费?
  • 任务进度:是否卡住?是否异常?
  • 节点健康状态:GPU是否掉卡?网络是否抖动?

一旦发现问题,调度器需要做「重调度」:要么迁移任务,要么杀死重启,要么调整资源配额。

三、调度流程的完整闭环

上面四个步骤不是线性的,而是一个循环。我画了一张图来展示这个闭环:

调度器工作流程闭环 ① 任务入队 Enqueue ② 资源匹配 Match ③ 资源分配 Allocate ④ 状态监控 Monitor 排序后 匹配成功 任务启动 异常/完成 → 重调度 核心目标驱动 吞吐量·公平性·利用率 虚线表示异常/完成后的重调度路径,实线表示正常流程

这张图我画了好几个版本才定稿。你看,从任务入队开始,经过匹配、分配、监控,最后又回到入队——这就是一个完整的闭环。为什么要有这个闭环?因为任务的状态是动态变化的,调度器必须持续响应。

四、实际项目中的调度策略选择

说了这么多理论,来点实际的。我在做调度系统时,一般会按以下步骤来选型:

  1. 先定目标:是优先保吞吐量,还是保公平性?这个得和业务方对齐
  2. 再选算法:根据目标选调度算法,比如FIFO、Fair、Capacity等
  3. 然后调参:队列长度、超时时间、资源预留比例,这些参数得慢慢调
  4. 最后加监控:没有监控的调度器就是盲人摸象

我的建议:刚开始做调度系统时,别搞太复杂。先用简单的FIFO+优先级队列跑起来,等摸清楚业务规律了,再逐步引入公平性策略和资源预留机制。我见过太多团队一上来就搞DRF、Gang Scheduling,结果半年了还没上线。

好了,这一章的内容就到这里。调度系统的核心目标和工作流程,说白了就是「想清楚要什么,然后按步骤来」。下一章我们会深入具体的调度算法,看看FIFO、Fair、Capacity这些算法到底是怎么工作的。

想要更多资料,联系可可