1. 从一张GPU账单说起:为什么异构算力调度成了AI平台的生死线
去年帮一个做多模态训练的团队看他们的算力账单,发现一个很典型的现象:8张A100的集群,GPU利用率长期在35%上下浮动,但训练任务排队时间却经常超过两小时。运维同学很委屈——任务提交上来的时候确实没卡,但跑着跑着就发现有的卡在等数据加载,有的卡在等CPU预处理,还有的卡被一个只用了2GB显存的小推理任务占着,剩下70多GB全空着。这就是典型的异构算力资源碎片化问题:GPU、CPU、内存、磁盘这四类资源各自为政,调度器只看GPU数量,完全不管CPU和内存的配比是否合理。
AllData数据中台集成Crater这个开源项目,要解决的就是这件事。Crater的定位很明确——AI训推一体化算力平台,把训练和推理两种截然不同的负载放在同一套资源池里统一调度。训练任务吃GPU显存和NVLink带宽,推理任务吃CPU和内存带宽,两者对磁盘IO的要求也完全不同。如果还是用传统的Kubernetes默认调度器,按GPU数量做整数分配,那资源浪费是必然的。
我先把这篇要讲的东西说清楚:Crater怎么接入AllData中台、异构资源怎么抽象、GPU/CPU/内存/磁盘四类资源怎么做联合调度、训推混部时有哪些坑、以及我实际跑下来觉得最值得抄的几个配置。适合正在搭建AI平台、被算力利用率折磨的运维和算法同学看,也适合想了解异构调度底层逻辑的开发者。
提示:本文涉及的所有配置和参数都基于Crater社区版的实际部署经验,不同版本字段可能有差异,建议对照官方文档核对。
2. Crater在AllData中台里的角色定位与接入方式
2.1 为什么不是直接上Kubernetes默认调度器
很多人第一反应是:Kubernetes不是有Device Plugin机制吗?装个NVIDIA的插件就能调度GPU了,为什么还要引入Crater?这个问题我一开始也问过。实际跑下来发现,K8s默认调度器有三个硬伤:
第一,GPU分配是整数级别的。一张80GB的A100,要么整张分出去,要么不分。但实际场景里,一个推理任务可能只需要10GB显存,一个微调任务需要30GB,剩下的40GB完全可以再塞一个任务。K8s原生做不到显存切分,除非上MIG或者vGPU方案,但MIG的配置粒度是固定的,不够灵活。
第二,调度维度太单一。K8s调度器主要看CPU和内存的request/limit,GPU只是作为一个扩展资源计数。它不会去判断“这个节点虽然还有1张GPU空闲,但CPU已经跑到90%了,再塞任务进去数据加载会成为瓶颈”。Crater的做法是把GPU、CPU、内存、磁盘IOPS四个维度做成联合打分,任何一个维度不满足阈值就不调度。
第三,训推混部缺乏优先级和抢占机制。训练任务通常跑几个小时甚至几天,推理任务是毫秒级响应。如果两者混在一起,推理任务被训练任务堵住就是灾难。Crater内置了优先级队列和抢占策略,高优先级的推理任务可以抢占低优先级训练任务的资源,被抢占的训练任务会自动checkpoint然后重新排队。
2.2 AllData中台与Crater的集成架构
AllData中台本身是一个数据资产管理平台,Crater作为算力调度层嵌入进去,整体架构分三层:
- 资源抽象层:Crater Agent跑在每个计算节点上,负责采集GPU显存、GPU利用率、CPU核数、内存容量、磁盘读写带宽和IOPS。这些指标每5秒上报一次到Crater Scheduler。
- 调度决策层:Crater Scheduler维护一个全局资源视图,收到任务请求后,先做资源过滤(哪些节点满足最低要求),再做打分排序(哪个节点综合得分最高),最后做绑定。
- 任务执行层:任务通过AllData中台的API提交,Crater把任务转成K8s的PodSpec,但扩展了自定义的ResourceClaim字段,用来声明显存大小、CPU核数、内存量、磁盘IOPS需求。
集成的关键点在于资源声明格式的统一。AllData中台对外暴露的接口是JSON格式,Crater内部用的是protobuf,中间需要一个转换层。我们当时踩的坑是:中台传过来的显存单位是MB,Crater默认是MiB,差了一个换算系数,导致任务实际拿到的显存比申请的多了一点点,虽然不影响运行,但资源核算对不上。后来在转换层加了一个单位归一化模块才解决。
2.3 部署Crater Agent时最容易忽略的三个细节
第一个细节是GPU指标采集的权限。Crater Agent需要读取nvidia-smi的输出,但在容器里跑的时候,默认没有权限访问GPU设备。必须在DaemonSet的securityContext里加上privileged: true或者至少挂载/dev/nvidia*设备。我们一开始只挂了/dev/nvidia0,结果多卡节点上只能看到第一张卡。
第二个细节是磁盘IOPS的测量方式。Crater默认用fio做基准测试,但fio跑一次要几十秒,频繁跑会影响业务。我们的做法是改成读取/proc/diskstats的增量,每5秒算一次IOPS,精度虽然不如fio,但足够调度用了。
第三个细节是Agent的心跳超时时间。默认是30秒,但在网络抖动的时候容易误判节点离线。我们调到了90秒,同时加了重试机制。这个参数在crater-agent.yaml的heartbeatInterval和heartbeatTimeout字段里改。
3. GPU/CPU/内存/磁盘四类资源的联合调度逻辑拆解
3.1 GPU显存切分:不是简单的除法
Crater的显存切分用的是动态分配+硬隔离的方案。每个任务申请显存时,Crater会在节点上创建一个CUDA Context,通过cudaMalloc预留指定大小的显存,然后把这个Context绑定到任务的进程组。这样即使任务内部有内存泄漏,也不会影响到其他任务的显存。
但这里有个坑:CUDA Context本身有开销。每个Context大约占用300MB左右的显存,如果一张卡上切了10个任务,光Context就吃掉3GB。所以Crater默认限制单卡最多切8个任务,超过8个就排队。这个限制可以在crater-scheduler-config的maxTasksPerGPU字段调整,但不建议调太高。
另一个坑是显存碎片。假设一张80GB的卡,先分配了30GB,再分配了20GB,释放了30GB,这时候虽然总空闲显存是60GB,但最大连续块可能只有30GB。Crater的做法是定期做显存整理,把碎片化的空闲块合并。整理频率默认是每小时一次,可以在配置里改成更频繁,但整理本身有开销,太频繁反而影响性能。
3.2 CPU与内存的配比约束
训练任务和推理任务对CPU/内存的需求完全不同。训练任务通常需要大量CPU做数据预处理,CPU:GPU比例大概在8:1到16:1之间;推理任务CPU需求少,但内存需求大,因为要缓存模型权重和中间结果。
Crater在调度时会检查CPU:GPU配比和内存:GPU配比两个约束。如果节点上GPU还有空闲,但CPU已经用了80%以上,调度器会给这个节点打低分,优先选其他节点。这个阈值可以在crater-scheduler-config的cpuThreshold和memoryThreshold字段配置,默认都是80%。
我们实际跑下来,把cpuThreshold调到70%效果更好。因为CPU跑到80%的时候,数据加载的延迟已经开始明显上升了,虽然还没到瓶颈,但训练速度已经受影响。提前在70%就停止调度新任务,整体吞吐反而更高。
3.3 磁盘IOPS的隔离与限流
磁盘是最容易被忽略的资源。训练任务读数据集的时候,顺序读带宽能跑满NVMe,推理任务加载模型的时候,随机读IOPS很高。两者混在一起,磁盘成为瓶颈,所有任务都变慢。
Crater的做法是给每个任务设置IOPS上限和带宽上限,通过cgroup的blkio子系统实现。训练任务默认给高带宽、低IOPS(顺序读为主),推理任务给低带宽、高IOPS(随机读为主)。这个分类是自动的——Crater会根据任务的镜像和启动命令判断是训练还是推理,也可以手动在任务描述里指定workloadType: training或workloadType: inference。
注意:blkio限流对NVMe的效果不如对SATA盘明显,因为NVMe的并发队列太深了。如果用的是NVMe,建议同时开启Crater的IO调度器,它会在应用层做请求合并和排序。
3.4 联合打分的计算公式
Crater的调度打分公式大致是这样的:
score = w_gpu * (1 - gpu_util) + w_cpu * (1 - cpu_util) + w_mem * (1 - mem_util) + w_disk * (1 - disk_util)四个权重默认都是0.25,但可以根据集群的实际情况调整。比如GPU密集型集群可以把w_gpu调到0.4,CPU密集型集群把w_cpu调到0.4。权重配置在crater-scheduler-config的scoreWeights字段。
我们集群的配置是w_gpu=0.35, w_cpu=0.25, w_mem=0.25, w_disk=0.15。调这个权重的依据是:GPU是最贵的资源,优先保证GPU不空闲;磁盘相对便宜,权重最低。调完之后GPU利用率从35%涨到了62%,效果很明显。
4. 训推一体化场景下的资源争抢与优先级设计
4.1 训练任务和推理任务的资源画像差异
先看一张对比表,这是我们在生产环境跑了三个月总结出来的:
| 维度 | 训练任务 | 推理任务 |
|---|---|---|
| GPU显存 | 大,通常20GB以上 | 小,通常4-16GB |
| GPU计算 | 持续高负载,利用率70-95% | 波动大,峰值高但均值低 |
| CPU | 高,数据预处理吃核 | 低,主要是请求调度 |
| 内存 | 中等,主要是数据缓存 | 高,模型权重常驻 |
| 磁盘 | 顺序读为主,带宽敏感 | 随机读为主,IOPS敏感 |
| 运行时长 | 小时到天级别 | 毫秒到秒级别 |
| 容错性 | 可checkpoint,可重启 | 要求高可用,不能中断 |
这张表是设计调度策略的基础。训练任务可以容忍被抢占(只要有checkpoint),推理任务不能容忍中断。所以Crater的优先级设计是:推理任务默认高优先级,训练任务默认低优先级,但训练任务可以声明“不可抢占”。
4.2 抢占与Checkpoint的配合机制
Crater的抢占流程是这样的:当高优先级任务需要资源,但集群没有空闲资源时,调度器会找一个低优先级任务,给它发送SIGTERM信号。任务收到信号后,有30秒的时间做checkpoint。30秒后如果还没退出,发SIGKILL强制杀掉。
这里的关键是checkpoint的自动化。我们要求所有训练任务必须集成Crater的checkpoint SDK,在收到SIGTERM时自动保存模型状态和优化器状态到持久化存储。没有集成SDK的任务,被抢占后只能从头开始跑,非常浪费。
实际跑下来,30秒的checkpoint窗口对大多数模型够用,但如果是百亿参数以上的大模型,保存一次要几分钟。这种情况可以在任务描述里设置gracePeriod: 300,把窗口延长到5分钟。但窗口越长,高优先级任务等待的时间也越长,需要权衡。
4.3 推理任务的弹性伸缩与冷启动优化
推理任务的特点是流量波动大,白天高峰、晚上低谷。Crater支持基于QPS的弹性伸缩:当某个推理服务的QPS超过阈值时,自动扩容副本数;低于阈值时,自动缩容。
但推理任务有个致命问题:冷启动慢。一个7B的模型,从拉镜像到加载权重到能响应请求,大概要2-3分钟。如果流量突然涨上来,扩容的副本还没起来,请求就已经超时了。
我们的优化方案是预热池:保持一定数量的“热”副本,这些副本已经加载好模型,但不接收流量。当需要扩容时,直接把热副本加入负载均衡,秒级生效。预热池的大小根据历史流量曲线动态调整,白天保持3个热副本,晚上保持1个。
这个功能Crater原生支持,配置在crater-inference-config的warmPoolSize字段。但要注意,热副本也占GPU显存,会降低整体利用率。我们的做法是把热副本调度到GPU利用率较低的节点上,用Crater的preferLowUtilization策略。
5. 实际部署中踩过的五个坑与排查过程
5.1 坑一:GPU显存分配成功但CUDA初始化失败
现象:任务申请了20GB显存,Crater显示分配成功,但任务启动时报CUDA_ERROR_OUT_OF_MEMORY。
排查过程:先看nvidia-smi,发现显存确实被占用了20GB,但任务就是跑不起来。后来用cuda-memcheck跑了一个最小复现,发现是CUDA Context创建失败。原因是Crater在分配显存时,只做了cudaMalloc,但没有创建Context。任务启动时创建Context需要额外的显存,而这时候显存已经被cudaMalloc占满了。
修复方案:在Crater的显存分配逻辑里,先创建Context,再cudaMalloc。Context的显存开销(约300MB)要算在任务申请的量里面。这个修复在Crater v0.8.2版本里已经合入。
5.2 坑二:CPU限流导致数据加载线程被饿死
现象:训练任务跑着跑着速度突然掉一半,看GPU利用率从80%掉到30%。
排查过程:用pidstat看任务的CPU使用,发现数据加载进程的CPU时间被压缩得很厉害。查Crater的cgroup配置,发现CPU quota设置得太紧。Crater默认给训练任务分配request级别的CPU,但limit设成了request的1.5倍。数据加载是突发性的,1.5倍不够用。
修复方案:把训练任务的CPU limit调到request的3倍,同时开启CPU burst功能。配置在任务描述里的cpuLimitMultiplier字段。调完之后GPU利用率稳定在75%以上。
5.3 坑三:磁盘IOPS限流误伤了模型加载
现象:推理任务启动时加载模型特别慢,从正常的30秒变成3分钟。
排查过程:查Crater的IO限流配置,发现推理任务的IOPS上限设的是500。但模型加载是随机读,7B的模型有几百个文件,每个文件都要随机读,500 IOPS根本不够。Crater自动分类把推理任务归到了“低IOPS”类别,但实际上模型加载阶段需要高IOPS。
修复方案:在任务描述里手动指定ioProfile: model-loading,这个profile给的是高IOPS、低带宽。或者更简单的做法:把模型文件打包成一个大文件,顺序读加载,这样用低IOPS高带宽的profile就够了。我们后来把所有模型都转成了safetensors格式的单文件,加载速度恢复了正常。
5.4 坑四:节点心跳丢失导致任务被误迁移
现象:任务跑了一半突然被迁移到另一个节点,从头开始跑。
排查过程:查Crater Scheduler日志,发现原节点的心跳超时了。但节点本身没挂,只是网络抖动了几秒。Crater默认30秒没收到心跳就认为节点离线,触发任务迁移。
修复方案:把heartbeatTimeout从30秒调到90秒,同时加了心跳重试。另外,对于已经运行超过10分钟的任务,即使节点心跳丢失,也不立即迁移,而是先尝试重连。这个逻辑在Crater v0.9.0里加了配置项migrationPolicy: conservative。
5.5 坑五:多卡训练的NCCL通信被CPU限流影响
现象:多卡训练时,NCCL的all-reduce操作特别慢,8卡训练比4卡还慢。
排查过程:用NCCL的调试日志看,发现all-reduce的延迟很高。查CPU使用,发现NCCL的通信线程被CPU限流了。NCCL做all-reduce的时候需要CPU参与协调,如果CPU被限流,通信效率直线下降。
修复方案:给多卡训练任务单独设置CPU策略,保证NCCL线程有足够的CPU时间。具体做法是在任务描述里加ncclCpuPriority: high,Crater会给NCCL相关线程设置更高的CPU调度优先级。另外,把cpuLimitMultiplier调到4倍以上。
6. 把GPU利用率从35%拉到70%:几个立竿见影的调优动作
6.1 显存超卖与安全水位
Crater支持显存超卖,也就是说,节点上所有任务申请的显存总和可以超过物理显存。这听起来很危险,但实际上大多数任务的显存使用是波动的,峰值只占申请量的70%左右。Crater的做法是设置一个安全水位,默认是物理显存的90%。当实际使用超过90%时,触发显存回收或者任务迁移。
我们集群的配置是overcommitRatio: 1.2,也就是允许申请120%的显存。同时把安全水位调到85%,留更多缓冲。跑下来没有出现过OOM,GPU利用率从35%涨到了55%。
6.2 训练任务的Checkpoint频率优化
Checkpoint太频繁会影响训练速度,太稀疏又会导致被抢占时损失太多。我们的经验值是:每30分钟一次全量checkpoint,每5分钟一次增量checkpoint。增量checkpoint只保存优化器状态的变化量,速度快,占用存储少。全量checkpoint保存完整模型,用于恢复。
Crater的checkpoint SDK支持增量checkpoint,配置在任务描述里的checkpointInterval和incrementalInterval字段。我们跑下来,增量checkpoint的开销只有全量的十分之一,对训练速度的影响可以忽略。
6.3 推理任务的批处理与显存复用
推理任务如果一次只处理一个请求,GPU利用率会很低。Crater支持动态批处理:把多个请求攒在一起,凑成一个batch再送进GPU。这样GPU的计算单元利用率更高,显存也能复用。
批处理的大小是动态调整的,根据请求的延迟要求来定。延迟要求高的服务,batch size小一点;延迟要求低的,batch size大一点。配置在crater-inference-config的maxBatchSize和batchTimeout字段。我们设置的是maxBatchSize: 32, batchTimeout: 10ms,GPU利用率从20%涨到了45%。
6.4 节点亲和性与数据本地化
训练任务读数据集的时候,如果数据在远程存储上,网络带宽会成为瓶颈。Crater支持数据本地化调度:如果某个节点上已经缓存了任务需要的数据集,调度器会给这个节点加分,优先把任务调度过去。
数据本地化的配置在任务描述里的dataLocality字段,可以设置preferred或required。我们设置的是preferred,因为required太严格,容易导致任务排队。配合Crater的分布式缓存,数据集在多个节点上有副本,调度器选最近的节点。
6.5 监控与告警的闭环
调优不是一次性的,需要持续监控。我们搭了一套监控看板,核心指标包括:GPU利用率、显存使用率、CPU使用率、内存使用率、磁盘IOPS、任务排队时间、任务平均运行时长。每个指标都设了告警阈值,比如GPU利用率连续10分钟低于30%就告警,说明调度有问题。
Crater自带Prometheus指标导出,配置在crater-monitor-config里。我们用的是Grafana做可视化,告警走的是Alertmanager。这套监控帮我们发现了不少问题,比如某个节点的GPU驱动版本不一致导致调度失败,就是通过监控看出来的。
7. 写在最后:一些个人体会
这套东西跑了大半年,最大的感受是:异构算力调度不是技术问题,是平衡问题。GPU利用率、任务等待时间、任务成功率、运维复杂度,这四个指标互相制约。你把利用率拉高了,等待时间就长了;你把等待时间压短了,利用率就下来了。Crater给了一套可调的参数,但最终怎么调,取决于你的业务更看重什么。
我们集群的最终配置是:GPU利用率目标65%,任务平均等待时间不超过5分钟,任务成功率99%以上。为了达到这个平衡,我们牺牲了一些利用率,换来了更稳定的任务体验。如果你的业务对等待时间不敏感,可以把overcommitRatio调高,利用率还能再涨。
另外,Crater的社区版功能已经够用了,但如果你需要更细粒度的显存隔离(比如按MB级别切分),或者需要跨集群调度,那得考虑企业版或者自己二次开发。我们目前是自己改了一部分调度逻辑,主要是加了自定义的打分插件,适配我们自己的业务优先级。
最后分享一个小技巧:Crater的调度日志非常详细,但默认只保留7天。建议把日志导到ELK或者Loki里,保留至少30天。我们有一次排查一个偶发的调度失败,就是翻了20天前的日志才找到原因。日志这东西,平时觉得没用,出问题的时候就是救命稻草。