接手这个项目之前,我最怕听到的一句话是:“集群又要排队了,申请点预算再买卡吧。”买卡确实能缓解,但也就缓解一阵子。真正的问题往往不是算力不够,而是算力明明摆在那里,任务却用不上。这次我把整个集群做了一次纯软件层面的重构:没有动任何一台服务器、没有加一块显卡,完全基于九章云极平台把CPU、GPU、国产加速卡这类异构算力统一纳管起来,再做智能调度优化,最终把集群整体利用率从不到30%拉到了接近70%,排队时间缩短了一半以上。
这套思路适合谁?如果你是运维工程师、平台负责人,或者团队里天天被训练排队和GPU浪费折磨的人,这篇文章会很有参考价值。我尽量把方案从设计思路、资源建模、调度策略讲到落地参数和踩坑记录,全文不含硬件改造,不涉及底层驱动魔改,所有内容都是可以在已有集群上复现的纯软优化路径。
1. 为什么坚持“零硬件改造”:先算清楚这笔账
1.1 硬件扩容从来不是第一选项
很多团队一提算力不足,第一反应就是“买卡”。有一次我盘点集群现状,发现卡利用率极低,但排队的作业却不少。这个现象非常典型,它在告诉你:瓶颈不在硬件总量,而在调度效率。真要是盲目扩容,等于花了钱养更多闲置设备。
硬件改造的隐形成本很容易被忽略:采购周期长、上架需要停机窗口、机房功耗和散热要跟着改、运维团队要重新学习硬件状态监控。就算这些都能搞定,新卡的驱动、固件、容器运行时配置也会消耗好几周时间。相比之下,纯软件改造可以在现有环境里灰度推进,试错成本低很多。
1.2 算力闲置比缺卡更常见
我接触过的集群,GPU平均利用率多数在15%到35%之间。这个数字听起来很低,但它是真实状态。原因也很简单:大量训练任务按峰值申请资源,比如某个算法工程师怕OOM,一次性申请8张卡,实际跑起来只有2张卡在满载,其余6张基本闲置。
更麻烦的是异构设备混用。集群里既有NVIDIA显卡,又有国产加速卡,每张卡的显存、算力、驱动要求都不一样。没有统一调度的时候,任务只会往“看起来有空闲”的机器上跑,结果碎片化越来越严重。排队队列很长,但机器其实是分散的空闲,这就是典型的假性算力不足。
1.3 纯软优化的边界与取舍
纯软优化不是万能的。如果任务本身到了计算密集极限,比如单卡训练已经跑满,那软件调度帮不了太大忙。但绝大多数业务场景属于“调度瓶颈型”:作业提交随意、资源申请虚高、队列没有优先级、碎片无法整合。这些问题靠调度策略完全能解决。
我的判断标准是:如果一张卡在业务的非高峰时段持续12个小时利用率低于10%,那就说明资源管理有问题,而不是硬件不够。优先把现有资源榨干,再去聊扩容的事情,这是零硬件改造方案的底层逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 异构算力统一纳管:先把“杂牌军”编成一张作战地图
2.1 资源建模与设备抽象
异构算力最头疼的地方在于每张卡都不一样。如果调度器只认“GPU=1”这种粗粒度,那么不同型号混合部署时,任务很可能会被调度到算力较差的卡上,影响训练速度。我们这次的做法是给每种设备建立多维资源模型。
我给每个加速卡定义了几个核心属性:型号、显存总量、计算单元数、峰值算力、驱动版本、健康状态。在Kubernetes环境里,这些信息通过自定义资源方式注册到节点上,调度器可以实时感知。光有“有没有空闲”不够,还要知道“空闲的卡能跑什么任务”,这样后续的智能调度才有数据基础。
除了设备型号,还要考虑单机多卡之间的通信方式。比如一张机器上4张卡是通过NVLink互联,还是走PCIe交换机,不同拓扑对训练通信效率影响很大。我们会在节点上记录GPU之间的连接拓扑,调度器分配任务时尽量把需要多卡通信的作业放到同一台机器,减少跨机网络开销。
2.2 设备健康检查与状态上报
纯软方案里最怕的就是“坏卡”混在可用列表里。训练任务跑着跑着突然报ECC错误、驱动超时,这些故障如果不及时隔离,调度器会把新任务继续往这台机器上塞,形成连环故障。
我们做了一个比较轻量的方案:在每个节点上跑一个探针脚本,定期用设备查询接口读取卡的状态、温度、功耗、报错计数。脚本把结果上报给调度中心,如果某张卡连续三次健康检查不通过,调度器会自动把这张卡标记为不可调度,不重启、不触发硬件操作,纯粹在软件层把它屏蔽掉。
这个设计落地很省事,但效果非常明显。以前一个月总有几次任务因为坏卡中断,现在节点出现异常时系统会自动转移负载,运维只需要在空闲窗口去人工处理硬件故障。
2.3 拓扑感知与亲和性规划
异构集群里还有一个容易被忽视的问题:CPU与加速卡的NUMA亲和性。CPU访问本地节点的内存带宽更高,如果任务被调度到跨NUMA的CPU上,数据搬运开销会明显增加。这个开销在短时推理任务上可能感觉不明显,但在大模型训练上会直接影响每步迭代时间。
我们在软件层记录每张加速卡对应的NUMA节点编号,调度的时候除了看卡是否空闲,还会看任务所在容器的CPU绑核范围是否与卡的NUMA节点一致。如果一致,就打高分;如果不一致,调度器会尝试在候选节点里重新规划。这个优化不追求100%理想拓扑,只求大多数场景避开最差的跨NUMA路径。
做统一纳管最大的收获是:以前“机房里有哪种卡”根本说不清楚,现在打开监控面板就能看到每台机器上每一类资源的实时状态、占用情况和健康信息。数据透明了,调度才有据可依。
3. 智能调度核心:把“抢卡”变成“算卡”
3.1 从单指标到多目标:调度不是只选一台机器
传统调度器往往只做一件简单的事:找到一个满足资源申请的节点,就安排过去。这种思路在资源充足时没问题,但在资源紧张时会造成严重不均衡。有的节点被塞满,有的节点闲置,任务排队越来越长。
这次我借鉴了智能制造里多目标调度优化的思路。在生产制造场景中,排产不仅要看设备是否空闲,还要综合考虑交期、能耗、工序约束、设备磨损等多重目标。算力调度也是同理:不能只看“能不能跑”,还要看“放在哪里跑最优”。
我最终确定的调度目标有四个:
- 提高整体资源利用率,避免任何节点长期空转
- 减少任务排队时间,尤其是高优先级任务的等待时延
- 尽量把碎片化的显存整合起来,让零散资源也能投入生产
- 控制集群功耗,避免大量任务集中造成峰值电费过高
这四个目标有冲突,比如“利用率最高”和“功耗最优”经常打架。解决冲突的办法是给每个目标设权重,形成统一打分函数,然后让调度器在候选节点中取最高分。
3.2 调度器打分函数设计思路
打分函数是整个智能调度的核心。我实现的版本大概是这样的逻辑:每个候选节点会基于当前状态被计算出一个分数,分数由资源适配度、任务亲和性、拓扑匹配度、节点均衡度、碎片化惩罚几个维度构成。权重不是拍脑袋定的,而是根据业务特征调出来的。
以资源适配度为例,不是简单看“剩余显存够不够”,还要看这张卡的型号是否符合任务要求。如果一个任务指定了A100,调度器绝不会把它放到其他型号的卡上。亲和性则考虑任务之前是否在这个节点上拉取过镜像、有没有缓存数据,放在已有数据的节点上能省大量准备时间。
碎片化惩罚很关键。比如有两个节点,同样剩余40G显存,但一个节点是2张20G卡,另一个节点是1张40G卡。对于需要单卡35G显存的任务,前者再大也跑不了,后者才合适。调度器必须能识别这种差异,否则任务会一直调度失败重试。
权重调优我在后面实操章节会讲,这里先说一个原则:每个权重都要有业务含义,不要盲目追求模型复杂度。先用简单线性加权能解释清楚问题,再逐步优化。
3.3 多级队列与优先级抢占
纯软优化最大的潜力来自队列和优先级管理。我们原来所有任务都进同一个队列,等于让算法工程师自己抢资源。后来我按业务维度拆分成了三个队列:在线推理队列、模型训练队列、离线分析队列。
推理任务对延迟极敏感,优先级最高;训练任务可以容忍排队,但一旦开始跑,希望资源稳定;离线分析任务能接受暂停和恢复,最适合“填谷”。三个队列各自设资源配额上限,避免某个队列把整个集群吃光。
抢占策略也要设计。我们选择的方案是:只有离线分析任务可以被抢占,训练和推理任务不会被杀掉。当高优先级队列资源不足时,调度器会先回收离线任务的空闲占用量。回收方式不是强制kill,而是先触发任务的checkpoint,保存进度后再释放资源。这样虽然会让离线任务晚点完成,但不会造成白跑。
3.4 CPU与GPU协同调度:别让CPU拖后腿
这个点容易被忽略。很多人只盯着GPU分配,忽略了CPU和内存的匹配。我曾见过一台机器GPU利用率只有40%,但CPU已经打满,数据加载成了瓶颈。原因就是数据预处理线程争抢CPU资源,模型计算每往前跑一步都要等数据。
我们专门做了CPU智能核心调度:根据任务类型判断它是计算密集还是数据密集,计算密集型任务优先分配高主频核心,数据密集型任务则分配更多核心数,保证数据预取线程顺利运行。同时把模型训练进程与数据加载线程做绑核隔离,避免互相干扰。
还有一个常被踩的坑是内存带宽。大模型训练时,CPU侧把数据从内存搬到显存,这个操作非常耗带宽。如果同一台机器上两个大任务同时做大规模搬运,整体速度会明显下降。调度器会预估任务的数据读取量,尽量把高带宽任务分散到不同机器,避免内存通道拥塞。
最终效果是,很多原来GPU利用率上不去的任务,经过CPU协同调度后利用率直接翻倍。原因是GPU大部分时间在等数据,数据喂饱了,计算卡自然忙起来。
4. 实操落地:从部署到参数调优全记录
4.1 部署前的前置检查清单
说实话,这套方案落地前,我先花了两天做环境盘点。软件的部署本身不复杂,复杂的是搞清楚现状。我列了一个前置检查清单:
- 所有节点上的设备驱动版本和容器运行时是否兼容
- 是否有节点的时间同步异常,时钟漂移会导致调度状态错乱
- 存储挂载路径是否统一,不同节点上数据目录不一致会影响缓存亲和
- 之前的作业脚本里是否写死了节点名称或IP地址
- 监控数据是否能覆盖到每个节点的每张加速卡
这些点全部确认之后,才开始部署调度组件。如果跳过检查直接上,后面大概率会被各种历史问题折磨。
4.2 调度器配置示例
在九章云极平台上,我们通过一段集中配置来管理整个集群的调度策略。我给出一份简化后的示例,重点是展示参数之间的关系,而不是直接抄生产配置。
yaml复制scheduler:
name: hybrid-scheduler
resource-filters:
- type: vgpu
key: "example.com/gpu-memory"
granularity: "MiB"
- type: accelerator
key: "example.com/accelerator-type"
values: [nvidia-a100, nvidia-v100, huawei-ascend]
queue-policy:
queues:
- name: inference
priority: 100
quota: 30%
preemptable: false
- name: training
priority: 70
quota: 50%
preemptable: false
- name: offline
priority: 30
quota: 20%
preemptable: true
scoring-weights:
resource-fit: 0.35
affinity: 0.25
topology: 0.20
balance: 0.12
fragmentation-penalty: 0.08
cpu-policy:
bind-core: true
isolate-data-loader: true
high-bandwidth-nodesets: [node-group-a]
这段配置里的关键点是scoring-weights。resource-fit权重最高,因为无论调度策略多花哨,任务必须先能放上去。affinity次之,因为数据亲和能实打实减少启动时间。fragmentation-penalty虽然权重最低,但绝不能设成0,否则资源碎片化会慢慢加剧。
4.3 灰度发布与效果评估
我没有直接在核心生产集群上全量切换,而是先挑了一个业务相对不敏感的子集群做灰度。灰度期为两周,第一周只观察不调优,第二周根据监控数据逐步调权重。
评估指标主要看三个:GPU/加速卡的平均利用率、任务排队等待时间的P95值、每单位算力消耗的作业产出。利用率提升了,但排队时间也变长,这不算成功;必须是利用率和任务完成速度同时变好,才是有效优化。
灰度期间我发现一个有意思的现象:利用率从30%提到60%很容易,但提到70%以上就明显吃力。原因是资源碎片的减少需要和任务粒度匹配,如果任务都是大头训练,零散显存始终拼不起来。后来我们配合了一部分支持分时共享的小推理任务,把碎片“吃”掉,利用率才继续往上走。
4.4 一次典型调优案例
让我用一次具体调优过程说明问题。灰度期间训练队列占满了高峰时段的资源,推理队列偶尔出现P95延迟超标。我最初的处理是直接把推理队列的quota从30%提到40%,结果训练任务排队时间大幅增加,两边都不满意。
后来我换了个思路:不调配额,调时间窗口。推理任务的高峰在白天,训练任务里面有很多能够夜间执行的小规模消融实验。我把这些实验任务标记为“可延迟”,调度器会把它们优先排到夜间填谷,白天高峰全部留给推理和正式训练。
这个改动上线后,推理延迟P95从1200毫秒降到800毫秒,训练总体的完成时间只增加了不到5%。问题解决了,而且没有增加任何硬件资源。这就是多目标调度中“时间维度调度”的价值:资源总量不够时,换个时间窗口放任务,效果立竿见影。
4.5 参数调优时的重要提醒
注意:权重调整每次只动一个维度,不要同时改多个参数。比如这次调
resource-fit,就固定其他权重不变,跑三天看效果。同时改两三个权重,出了问题你根本不知道是哪个参数导致的。
我在第二周调权重时踩过这个坑,一次把topology和balance都改了,结果任务平均等待时间变长了,排查了整整半天才定位到是balance权重过高,导致调度器过于追求节点均衡,把大量任务分散到了不同机器,反而破坏了数据亲和。恢复原值之后问题立刻消失。
5. 常见问题与排查实录
5.1 明明有空闲卡,任务却调度不上
这个问题最常见,排查起来也最容易绕弯。第一反应往往是调度器出bug了,但九成情况是资源模型和任务要求不匹配。比如节点剩余显存是80G,但分散在4张20G卡上,而任务申请的是单卡40G,自然调度不上。
我们的解决方案是给任务增加“允许切分”的标记。训练任务默认不允许切分,推理任务允许切分到多张卡上。这样大规模训练任务走整卡分配,小推理任务可以共享剩余显存,碎片就被盘活了。
5.2 显存OOM与任务被杀
任务提交时申请的显存和实际运行所需显存经常不一致。有的任务申请了80G显存,实际只跑40G,浪费了整卡的另一半;有的任务申请60G,但代码里有泄漏,跑一会儿就打到80G,直接把卡撑爆。
我们做了两层防护。第一层是在任务启动前做显存预检,如果节点剩余显存小于任务申请的1.2倍,就不允许调度。第二层是在容器内加监控,显存使用率超过阈值时提前告警,而不是等OOM发生后任务被强杀。告警之后运维可以把任务先挂起,等代码修复后再恢复,从“任务白跑”变成“任务暂停后可续”。
5.3 调度器自身成为瓶颈
集群规模大了之后,调度器本身也可能成为单点瓶颈。比如任务提交量每天上千次,每次调度都去查询全量节点状态,响应就会变慢。我们一开始没注意,后来发现任务排队时间没降反升,查监控发现调度器CPU使用率已经打满。
我的处理方案是给调度器加了两层缓存:第一层缓存节点资源状态,每30秒更新一次;第二层缓存历史调度决策,如果连续多个任务请求相同资源类型,直接返回上次的候选节点列表,然后只做增量校验。这两步把调度平均耗时从原来的3秒降到了不到1秒,集群整体吞吐明显提升。
5.4 节点间时钟漂移导致状态错乱
这个坑比较隐蔽,生产过程出问题的时候排查了很久。节点时钟不同步,监控数据和实际状态对不上,调度器拿到的时间戳是乱的,导致部分任务被误判为超时,触发无效抢占。后来在启动检查清单里把“时间同步”列为必检项,并且让监控系统定期校验各节点时钟偏差,超过50毫秒就告警。
5.5 镜像拉取造成的长尾时延
调度器把任务放到一个节点,但节点上没有对应镜像,需要现场拉取。大模型镜像动辄十几GB,内网再快也要几分钟。这个时间算进排队时间后,调度效果会被严重拉低。我们通过镜像预热解决:根据历史调度记录,把常用镜像提前推送到候选节点,并定期更新。这属于不改变任何硬件的“软优化”,但对实际体验的提升非常明显。
6. 落地之后的几点真实体会
这套纯软优化方案我已经稳定运行了一段时间,最大的感受是:算力调度这件事,硬件的边际收益越来越小,软件的空间反而比想象中大。不需要买新卡,不需要重新组网,只要把资源用模型描述清楚、把调度目标定义清楚、把策略灰度跑起来,很多“缺算力”的问题其实就是“缺调度”。
最后再分享一个实用性很强的技巧:不要只盯平均利用率,要看P95和P99。平均数好看的时候,极端延迟问题可能依然存在。多目标优化的本质不是追求一个漂亮数字,而是让不同业务在同一个集群里尽量都过得舒服。这是我在这个项目里最大的收获,也是后续持续调优的方向。
