跳过正文
  1. Posts/

当资源利用率开始影响 Quota:一次训练集群资源治理机制的设计与反思

·6323 字·13 分钟
目录

起因
#

三年前我参与维护过一个多人共享的训练集群。集群上跑着各业务线的训练任务,用户通过一个队列系统提交,每个用户有自己的资源 Quota。

有一段时间,Grafana 上有个现象我一直没想明白:集群整体 CPU 利用率并不算高,但 scheduler 那边不断有任务 pending。按 dashboard 的数字,资源还有富余;按用户的体感,机器早就满了。两边对不上。

后来把 request 和实际 usage 并排看了一遍,原因不复杂:request 虚高。实际需要 20 CPU 的任务,request 填的是 64,甚至 100。调度器按 request 做占位和调度,占住但没用上的那部分,别人也拿不到。

我当时的判断是「让大家填准一点」,加几条规则或者发个通知就能缓解。真正做进去之后才发现,它背后是一个更一般的治理问题:共享资源系统怎么让资源申请这件事形成闭环。

填大一点,是理性选择
#

先替用户说句话:填大 request 的人并不是在乱填。

训练任务对资源的需求本来就不容易在提交时估计准。数据加载、预处理、分布式同步、checkpoint,每个阶段对 CPU 的需求都不一样,预处理峰值和训练稳态能差一个数量级;模型结构、batch size、数据 pipeline 一变,上一次的经验又不完全适用。

在这个前提下,用户的决策完全说得通:

1实际可能需要 20 CPU
23为了保险,申请 40 / 64 / 100 CPU

因为:

1申请少了
2→ 任务可能变慢 / OOM / 失败
3
4申请多了
5→ 对自己通常没有直接成本

这是这个系统里一个理性的个体选择。问题在于,所有人都这么做之后,结果对谁都不好:

1request 远大于 actual usage
23scheduler 认为资源已经被占用
45其他任务无法调度
67大量 CPU 实际处于 idle
89集群整体利用率下降

我后来在别的共享计算场景里也见过同款结构:个体多占一点,好处归自己,代价由所有人分担。把它归结为「用户不守规矩」没有意义,因为换一批用户、换一个团队,只要成本结构不变,行为还会回到同一个均衡上。

系统的激励方向有问题,靠要求个体自觉是修不动的。

静态 Quota 缺了半截回路
#

平台的 Quota 机制本身没做错什么。它回答的问题只有一个:一个用户最多能占用多少资源。为了尽量公平,还按工作繁忙时间和非繁忙时间做了区分。

整个链路大概是:

1User
23Resource Request
45Quota Check
67Scheduler
89Job

request 进来,查一下有没有超过 quota,没超就放行。任务跑完,实际用了多少 CPU、多少内存、GPU 空转了多少,这些数据都会进监控系统。

然后就没有然后了。CPU utilization、memory utilization 都是监控指标,它们的去处是 Grafana,不是下一轮资源分配。

也就是说,用户把 request 填大以后,系统不会有任何反应;填准了,也不会有任何好处。在这个结构里要求用户主动校准,等于要求他做一件对自己没有收益的事。

资源使用结果如果不影响未来的资源获取能力,认真填写 request 就缺少理由。

所以很自然的一个想法:把 usage 反馈到 quota 里,让申请这件事自己形成回路。

 1Job
 2 3Resource Usage
 4 5Score
 6 7User Quota
 8 9Future Jobs
1011Resource Usage
12 ↑___________|

这个回路一旦转起来,评价一个用户的不再只是他「申请了多少」,还有他「用得怎么样」。

一开始我只看利用率
#

要做评分,最先想到的口径是 utilization:一个任务的 CPU 利用率越低,说明 request 越不合理。

这个直觉对了一半——它抓住了「用没用好」,但漏掉了「占了多少」。

举个例子:

1Job A
2request = 2 CPU
3duration = 1 分钟
4utilization = 10%
5
6Job B
7request = 200 CPU
8duration = 10 小时
9utilization = 10%

只看 utilization,两个任务一模一样,都是 10%。但从集群角度看,这两个任务完全不是一回事。A 浪费的是 2 个 CPU 一分钟里的九成,总共不到 2 个 CPU 分钟;B 占着 200 个 CPU 十个小时,其中 180 个 CPU 长时间空转。

更准确地说,资源浪费不是「某一刻有多少比例没用上」,而是「有多少资源,被空占了多久」。它天然有资源量和时间两个维度:

1Resource
2 ^
3 |---------------- requested resource
4 |
5 |      actual usage
6 |
7 +------------------------------> time

真正浪费的部分,更接近 request 曲线和 actual usage 曲线之间随时间积累的面积。

写成积分就是:

$$ \text{wasted} = \int_{0}^{T} \left( r(t) - u(t) \right) dt $$

其中 \( r(t) \) 是 request,\( u(t) \) 是实际使用。落到采样粒度上,它近似于:

$$ \text{wasted cpu seconds} \approx \text{requested cpu seconds} - \text{actual cpu seconds} $$

**利用率是一个比例,而资源浪费是一个带时间维度的量。**这句今天看很朴素,但当时这个转换花了我不少时间,因为它决定了后面所有行为的口径。用比例评分,系统会去治那些「百分比难看」的小任务;用面积评分,系统才能看见真正吃资源的大任务。

当年的打分是怎么来的
#

最终在系统里跑起来的任务评分,大致是这样一个形式:

$$ \text{task score} = \log(\text{cpu request} \times \text{running time} + 50) \times \exp(2.5 - \text{max cpu utilization}) $$

利用率按 0 到 1 取。拆开看,这个式子只做两件事。

第一部分 \( \text{cpu request} \times \text{running time} \) 是任务的 resource-time footprint:占了多少资源,占了多久。外面套一层 log,是因为任务之间的规模差太远——一个跑几小时的分布式训练,和一个跑几分钟的数据预处理,按 footprint 线性比较的话,小任务会被完全淹没。log 让大任务仍然得到更高权重,但不会出现一个巨型任务支配用户全部历史评分的情况。

第二部分的 exp 表达惩罚:利用率越低,惩罚越大,而且低到一定程度以后惩罚上升得更快。用指数而不是线性,是希望把「偶尔用一半」和「长期只用到两成」这两类任务明显区分开。

说白了,这不是推导出来的最优函数,是一个工程 heuristic。log 的底、2.5 这个常数、那个 50,都是当时对着历史数据试出来的,今天我不会硬说它有什么理论依据。这些常数也不是这套机制里重要的部分。

重要的是它的结构:

1waste severity
23resource-time footprint
4      ×
5utilization penalty

也就是同时回答两个问题:你占了多少资源、占了多久;这些资源有没有真正用起来。

一个更直接的做法是直接算 wasted cpu seconds,但当时没有这么做。原因之一是历史采样不全,部分任务算不出可靠的积分,能稳定拿到的只有 request、running time 和采样峰值。是用一个弱化但可得的指标,还是等数据补齐再上线,当时选了前者。这个妥协的账,后面我会重新算。

打分最后落在人头上
#

任务评分做出来,马上会遇到一个问题:它该怎么用。

惩罚任务本身没有意义。任务已经跑完了,给它的分数再低,也不会释放半点资源。真正能改变的是下一轮的申请行为,而决定 request 的是人。

一个用户长期把 request 填得虚高,那么需要被收紧的是这个人未来提交任务的自由度,而不是他历史上某个具体任务的名声。所以 score 要聚合到 user:

$$ \text{user score} = \frac{\text{AVG}(\text{task score})}{\log(\text{job count} / 10 + 1)} $$

然后按 user score 排名。表现靠前的用户拿到更高的 quota coefficient,靠后的拿到更低的。当时配置过类似这样的系数:最好的用户大约 3,最差的用户大约 0.25。如果基础 quota 是集群资源的 30%,对应的实际额度就是 90% 和 7.5% 这个量级。

数字本身没有多少含义,它只是当时的配置。这个设计里我更想记下来的是治理对象的切换:

1Job₁  Job₂  Job₃  ...
23historical behavior
45User Score
67Future Quota

用今天的话说,这套东西可以看成 user-level 的 resource reputation:过去是否有效使用了共享资源,会影响未来拥有多少资源自由度。分母里那一项当时想得并不清楚,后面会讲到。

Quota 不再只是上限
#

整个改造里,对我而言最重要的变化是 quota 这个词的含义变了。

以前它是一句约束:你最多能用多少资源。静态、单向,跟历史无关。

改造以后,它变成:你过去怎么使用资源,决定你未来有多少使用资源的自由度。它开始承载信息,而不只是一个数字上限。

用反馈回路的角色对照一下:

1Metrics                    →  Sensor
2User Score                 →  Aggregated State / Reputation
3Quota Coefficient          →  Actuator
4Workload + User Behavior   →  Controlled System
Quota 反馈回路:usage 指标经评分聚合,改变下一轮 quota

这里不用往控制理论里走。我想说的只有一件事:资源利用率不该永远只是 dashboard 上的一条曲线,它也可以是下一轮资源分配的输入。

一旦 quota 接上 usage,用户面对的成本结构就变了:

1过度申请资源
23历史利用率差
45未来 quota 下降
67过度申请开始产生实际成本
89用户逐渐更认真地填写 request

这个思路的一个好处是,它对平台的要求反而变低了。平台不需要算准「这个任务应该申请 24 CPU」——这件事连用户自己在提交时都算不准,需求本身就是随实验动态变化的。平台只需要如实观测、如实反馈,让错误的申请慢慢产生代价。

预测 workload 需要理解每一个模型和每一份数据;建立反馈只需要把已有的观测接进决策。这两种能力的成本完全不是一个量级。

为什么没有直接缩当前任务
#

更激进的做法是:发现某个任务 CPU 利用率低,立刻缩它的资源,甚至 kill 掉重排。

当时我们没有做这一步,原因和训练 workload 的形态有关。训练任务的资源曲线有明显的阶段性:

1startup → data loading → preprocessing → 训练 → checkpoint / I/O

数据加载的时候 CPU 打满,进入训练阶段后 CPU 降下来(算力在 GPU 上),checkpoint 写盘的时候 CPU 又冲上去。短时间的低利用率,很可能只是它正常节律里的一个低谷。

如果系统盯着分钟级的 utilization 做即时决策,很容易砍在这个任务马上要用的资源上,或者在它本来就很脆弱的阶段再补一刀。训练任务是长任务,重启成本高,这种误伤不划算。

所以当时的回路是有意做慢的:

1过去一段时间的行为
23影响未来一段时间的 quota

而不是:

1这一分钟利用率低
23马上修改当前 Job

它更像一个 slow feedback loop:牺牲响应速度,换更低的误伤概率,也给用户留出理解规则的时间。

这不是说慢一定好。资源非常紧张、需要快速腾挪的时候,慢回路确实不够用。但在一个任务普遍要跑几小时到几天的训练集群里,以天为单位的调节节奏够用了;反过来,如果反馈快到跟着分钟级波动走,用户会完全看不懂系统在做什么,回路自己也会抖。这个取舍取决于被控系统的响应时间,而不是哪个方案听起来更先进。

解释不清楚,反馈就断了
#

还有一个当时只是顺手做的小工具,后来越看越重要。

机制跑起来以后,用户会发现自己这个周期的 quota coefficient 变了,然后来问为什么。如果平台只能回答「你的资源使用效率较低」,这个反馈其实是断掉的——用户既不知道是哪些任务拖了后腿,也不知道该怎么改。

所以当时加了一个查询:列出一个用户近期 CPU 利用率最低的若干任务、内存利用率最低的若干任务,输出大致是:

1jobname | request_cpu | max_cpu_utils | ...

查询里还会过滤掉一些不适合拿来说明问题的任务:微小的控制类任务,利用率低是正常的;以及失败的任务,它们没跑完,原因可能和资源申请无关。

这件事的价值不在于工具贴心,而在于回路能不能真正闭合:

1observation → score → quota → explanation → human behavior change

前面几步都在机器内部,只有最后一步——用户真的改了填写习惯——才会产生系统想要的资源效果。而最后一步只能靠解释来触发。对于存在人的系统,可解释性本身就是控制回路的一部分。

三年后重新看这套机制
#

离开那个环境之后,我又在别的地方做过调度和容量相关的事情。最近重新把这套机制翻出来想了一遍,结论是:feedback loop 的方向我认为仍然成立,但具体打分的实现,有几处今天我会重做。

max cpu utilization 不是好的效率指标
#

当年的评分依赖 max_cpu_utils,也就是 CPU 利用率的采样峰值。

一个任务可能第一分钟 CPU 打到 90%,之后九个小时都在 5%。它的 max utilization 是 90%,但资源效率显然不是 90% 的水平。这个指标更像是「这个任务有没有努力过」,而不是「它到底浪费了多少」。

如果今天重新实现,我会直接从前面那个面积定义出发:

$$ \text{efficiency} = \frac{\text{actual cpu seconds}}{\text{requested cpu seconds}} $$

或者反过来:

$$ \text{wasted cpu seconds} = \text{requested cpu seconds} - \text{actual cpu seconds} $$

从 peak 走向 integral。这个改动的意义是把评分和资源模型对齐:既然浪费是面积,就该用面积来度量。当年选 peak 有数据可得性的原因——历史采样不全,完整积分算不出来——但这个妥协确实损害了指标的含义,数据补齐之后应该重算。

相对排名只能回答谁更差
#

当时的机制是按 user score 排名,最好的拿最大 coefficient,最差的拿最小。

这有一个结构性问题:如果所有人都表现很好,90%、89%、88%,仍然必须有人当最后一名,拿最低的 coefficient;反过来,如果整个集群的 request 都填得离谱,15%、10%、5%,也一定会有人被系统认为「相对不错」。

排名回答的是「谁比谁好」,平台真正关心的是「做得好不好」。分布整体平移的时候,这两个问题的答案会完全相反。如果今天重新设计,我会让绝对效率分数成为主体,叠加对历史需求量与公平性的考虑,排名最多作为辅助信号。

一个分数混了三件事
#

回头看 \( \text{AVG}(\text{task score}) / \log(\text{job count} / 10 + 1) \) 这一行,它其实同时装着三件不同的事:

  • efficiency:你过去的资源用得怎么样
  • demand:你是不是真的有大量计算需求
  • confidence:观察到的样本够不够,这个评分可不可信

一个用户任务多,既可能说明他需求大,也可能说明他刚来在反复试;任务少,可能是需求小,也可能是样本还不够。这些情况在当时的公式里区分不开,最后只能靠手动调系数去「感觉差不多」。

今天我会把它们拆开:

$$ \text{quota} = f(\text{efficiency}, \text{demand}, \text{confidence}) $$

具体 \( f \) 长什么样是第二位的,重要的是每一项都能单独解释、单独调整、单独回测。一个混在一起的分数,出问题的时候没法 debug——你不知道是哪个假设错了。

指标一旦影响 Quota,就不再只是指标
#

这条是三年后回看时我觉得最该补上的。

job count 是个好例子。把它放进 user score 的一部分想法是:经常跑任务的人确实有更大的资源需求。但一旦 job count 影响 quota,用户理论上就可以把一个大任务拆成十个小任务,来改变自己的评分。

我不是说真的有人这么干过。重点是这种可能性从指标进入利益分配那一刻起就存在了。

**监控指标和激励指标是两种东西。**一个指标只用于观察的时候,它能反映系统状态就够了;一旦它开始决定资源、钱或者任何用户在意的东西,它就变成了 incentive mechanism 的一部分,必须额外回答几个问题:

1用户能不能操纵它?
2它对不同工作模式的人公平吗?
3它能不能被解释清楚?
4它会不会诱导出我们不想要的行为?

Goodhart 那句话——当一个度量变成目标,它就不再是一个好的度量——放在这里很贴切。这次经历给我的实际教训是:任何准备接进 quota 的量,都应该先过一遍「如果有人专门针对它优化,会发生什么」。当年这一步只做了很小一部分,靠的是「用户应该不会那样做」的假设,而不是机制上的防御。

回路本身会不会震荡
#

还有一条是关于回路稳定性的。

历史窗口、周期性刷新排名、quota coefficient,这几件事合在一起已经是一个控制回路。是回路就会有稳定性的问题。能想到的一种摆动:

 1某用户表现好
 2 3quota 增加
 4 5他提交更多、更大的任务
 6 7短期内 efficiency 下降
 8 9排名大幅下滑
1011quota 又被砍回去

如果刷新周期和用户的反应速度接近,这类摆动会一直存在。用户觉得系统莫名其妙,系统觉得用户忽好忽坏。

工程上有很多简单的处理:EWMA 平滑、hysteresis、dead band、最小样本量、限制单次 quota 调整幅度。不需要真的去建控制模型。当时这套机制里这些都没有,每轮快照对上谁就是谁。今天做的话,至少会给 quota 的变化速度限个速。方向正确只是第一步,回路还要能稳定下来。

回到那个问题
#

再回到最开始那个对不上的数字:集群利用率不高,任务却在排队。

这个问题的形状决定了它没法靠一条规则解决。平台很难在上游知道一个新 workload 的「正确资源需求」——这件事连用户自己在提交时都不知道,因为需求是随着实验演进的。任何试图直接预测正确答案的努力,都会在出现新模型、新数据、新 pipeline 的时候失效。

能做的,是把系统做成一个会自己修正的系统:

1observe → measure → feedback → change future behavior

让错误的资源申请慢慢产生成本,让长期认真使用共享资源的用户拥有更多自由度。这套机制不需要平台足够聪明,它需要的是平台足够诚实:如实观测,如实反馈,把代价还给制造代价的人。

三年之后的我对具体算法的评价比当年更挑剔:max utilization 应该换成 integral,相对排名应该让位给绝对评分,混在一起的含义应该拆开,任何进入分配的指标都要先想清楚它会被怎么利用,回路本身还需要稳定性设计。这些都是需要改的地方。

但最底层的那个判断我没有改:Quota 不只是一个静态上限,它也可以是资源治理 feedback loop 里的 actuator。

再往外看一层,很多 infra 问题的形状都是这样:看起来缺的是一个更聪明的算法,能一次性算出正确的资源配置;实际上缺的是一个把观测、策略和反馈串起来的机制,让系统长期向更合理的状态收敛。

参考资料
#

相关文章