基础概念:任务、编排、调度、资源池、队列、依赖关系等核心术语

在构建训练任务编排与调度系统之前,必须先建立一套精确的术语体系。训练平台与普通 Web 后端最大的差异在于:它管理的是有状态、长耗时、强资源依赖的计算负载。如果团队对“任务”和“作业”的定义不一致,后续的 API 设计、数据库表结构、调度策略都会产生歧义。本章将围绕六个核心术语展开,厘清它们的内涵、边界与相互关系。

一、核心术语定义与边界

术语 英文 核心定义 典型粒度 生命周期
任务 Task 一次最小可执行的训练单元,包含代码入口、超参、资源请求 单进程 / 单节点 秒级 ~ 小时级
作业 Job 一组任务的集合,通常对应一次完整的训练实验 多任务 / 多节点 分钟级 ~ 天级
编排 Orchestration 定义任务之间的执行顺序、数据流转与重试策略 DAG / 工作流 静态定义
调度 Scheduling 在满足约束的前提下,将任务分配到具体资源的过程 队列 / 节点 动态决策
资源池 Resource Pool 具有相同硬件规格和软件环境的节点集合 GPU 集群分区 长期存在
队列 Queue 具有优先级、配额和调度策略的任务等待区 组织 / 项目 长期存在

需要特别区分任务(Task)与作业(Job)。在 Kubernetes 生态中,一个 PyTorchTrainingJob 可能包含 1 个 PS 任务和 4 个 Worker 任务;而在 Argo Workflows 中,一个 Workflow 则包含多个顺序执行的 Template。因此,在设计系统时,建议将“作业”作为用户可见的提交单元,将“任务”作为调度器实际调度的最小单元。

术语约定建议:在数据库设计中,使用 job_id 关联一组 task_id;在监控面板中,用户按作业维度查看,运维按任务维度排查。避免混用“任务”指代不同层级的对象。

二、编排与调度的职责分离

编排与调度是系统中最容易混淆的两个概念。编排回答“做什么、按什么顺序做”,调度回答“在哪里做、何时做”。以一次分布式训练为例:

# 编排层定义(伪代码)
job = Job(name="resnet50-cifar10")
job.add_task("data-loader", image="data:v1", resources={"cpu": 2})
job.add_task("trainer", image="train:v2", resources={"gpu": 4}, depends=["data-loader"])
job.add_task("evaluator", image="eval:v1", resources={"gpu": 1}, depends=["trainer"])

# 调度层决策(伪代码)
scheduler.assign(task="trainer", node="gpu-node-03", gpus=[0,1,2,3])
scheduler.assign(task="evaluator", node="gpu-node-07", gpus=[0])

编排层在作业提交时即生成完整的 DAG 结构,而调度层则在每个任务就绪时实时决策。这种分离带来两个好处:一是编排逻辑可以独立测试,不依赖集群实时状态;二是调度策略可以热更新,例如从“深度优先”切换为“广度优先”而不影响已提交作业的定义。

关键设计原则:编排结果应持久化存储,调度结果仅保存在内存或 etcd 中。当集群重启后,调度器可以根据持久化的 DAG 重新调度未完成任务,而无需用户重新提交作业。

三、资源池与队列的层级关系

资源池是物理资源的逻辑抽象,队列是用户请求的逻辑抽象。两者通过配额(Quota)绑定。一个资源池可以服务多个队列,一个队列也可以跨多个资源池(但通常不建议)。

# 资源池定义示例
resource_pool:
  name: "A100-pool"
  node_selector:
    gpu_type: "A100"
  capacity:
    total_gpus: 64
    total_memory: "2TB"

# 队列配额示例
queue:
  name: "algorithm-team"
  namespace: "alg"
  quota:
    max_gpus: 16
    max_running_jobs: 8
    priority: 500

调度器在决策时,首先检查队列剩余配额,再检查资源池空闲资源,两者同时满足才允许调度。这种双层校验机制可以有效防止单个团队耗尽集群资源,同时保证硬件利用率。

四、依赖关系的类型与表达

依赖关系是编排的核心。除了常见的“前序任务成功后才执行”的硬依赖外,还有三种容易被忽视的依赖类型:

  • 数据依赖:任务 B 需要读取任务 A 产出的 checkpoint 或数据集。即使任务 A 失败,只要产物完整,B 仍可执行。
  • 资源依赖:任务 B 需要任务 A 释放 GPU 后才能运行。这种依赖通常由调度器隐式处理,但编排层可以显式声明以优化等待时间。
  • 条件依赖:根据任务 A 的输出结果决定是否执行 B 或 C。例如,当验证准确率 > 0.95 时触发模型导出,否则触发调参任务。
# 条件依赖表达示例
if trainer.metrics.accuracy > 0.95:
    run("export-model")
else:
    run("hyperparameter-tuning")

在实现层面,依赖关系通常用 DAG 的邻接表存储,每个任务节点维护 dependencies 列表和 dependents 列表。当任务完成时,调度器遍历其 dependents,检查是否所有前置依赖均已满足,若满足则将该任务放入就绪队列。

五、案例:一次完整的训练作业流转

假设算法工程师提交了一个包含数据预处理、分布式训练、模型评估三个阶段的作业,系统内部将经历以下流转:

  1. 提交阶段:API 网关接收作业定义,校验 DAG 合法性(无环、资源请求不超过队列配额),生成 job_id 并持久化。
  2. 编排解析:将作业拆分为 3 个任务节点,建立依赖边:data-prep → trainer → evaluator。
  3. 调度决策:data-prep 首先进入就绪队列,调度器在“CPU 资源池”中找到空闲节点,分配 2 核 CPU 启动容器。
  4. 数据流转:data-prep 完成后将处理后的 TFRecord 写入共享存储(如 CephFS),并更新任务状态为 succeeded。
  5. 级联触发:调度器检测到 trainer 的所有依赖满足,将其放入高优先级队列,等待 GPU 资源池中有 4 张空闲 A100。
  6. 资源抢占:若 GPU 资源不足,调度器根据队列优先级决定是否抢占低优先级任务的资源。
  7. 完成回收:evaluator 运行结束后,作业状态置为 finished,释放所有资源,保留日志与产物路径。

常见误区:许多系统将“作业状态”与“任务状态”混为一谈。实际上,作业可能处于 running 状态,但其某个子任务已 failed 并正在重试。监控系统必须区分展示,否则用户会误以为整个作业失败而手动取消。

六、术语关系全景图

下图展示了上述核心术语在系统中的静态关系与动态流转路径,帮助读者建立整体认知:

用户提交层 作业 (Job) DAG 定义 超参 / 资源请求 编排层 (Orchestration) 任务 A 任务 B 任务 C 依赖关系: A → B → C (条件/数据/资源) 调度层 (Scheduler) 队列 Queue 优先级 / 配额 / 策略 资源池 Resource Pool GPU 节点集合 / 标签 调度决策 配额校验 + 资源匹配 执行层 (Execution) Pod / 容器 共享存储 日志采集 监控指标 状态反馈 状态流转: Pending → Ready → Running → Succeeded / Failed / Retrying 作业状态与任务状态分离,通过 DAG 依赖关系级联更新

图中清晰地展示了三个层次:用户提交的作业经过编排层拆解为带依赖关系的任务 DAG,调度层结合队列配额与资源池空闲情况做出分配决策,最终在执行层以容器形式运行并反馈状态。值得强调的是,队列与资源池之间是多对多的关系,但为了系统稳定性,建议初期采用“一个队列绑定一个资源池”的简化模型,后续再逐步开放跨池调度。

七、常见误区与复盘

误区一:将“任务”等同于“容器”。一个任务可能对应多个容器副本(如分布式训练中的 worker 有 4 个副本),也可能一个容器内串行执行多个任务步骤。正确的抽象是:任务 = 逻辑执行单元,容器 = 物理运行载体。

误区二:忽略依赖类型导致死锁。如果只实现“成功依赖”而不处理“数据依赖”,当任务 A 失败但产物完整时,任务 B 会一直等待,造成死锁。建议在依赖检查中加入产物校验逻辑。

复盘建议:在系统设计评审时,用真实训练作业走一遍术语检查表:这个对象是用户可见的吗?它的生命周期由谁管理?失败后谁负责重试?资源释放由谁触发?通过这些问题可以快速暴露术语边界模糊的地方。