智算中心的机器越堆越多,但真正让平台团队头疼的往往不是买卡,而是怎么把手里这些不同品牌、不同架构的GPU和NPU管起来用起来。如果你也在做类似的事,或者正准备搭一套异构算力管理平台,这篇内容应该能帮你少走不少弯路。
我从一个平台开发者的视角,结合我自己在做的东西,把“不同品牌GPU与NPU统一管理”这个事拆开讲讲。里面会有架构设计思路、实际踩坑记录、调度方案选型的对比,也有一些可以直接拿过去用的配置示例和排查技巧。适合正在做智算平台、AI训练平台、或者准备把异构设备纳入统一调度体系的开发同学参考。
1. 异构算力管理的痛点与整体设计思路
先说个我自己的感受:异构算力管理这件事,难点不在某个单独的设备怎么用,而在怎么让一堆彼此差异巨大的设备,跑在同一个平台体系里还不出乱子。
1.1 异构设备多了之后,先踩到的是哪些坑
GPU和NPU在硬件形态、驱动接口、显存管理、算子栈上都不一样。比如英伟达的GPU有CUDA生态,AMD的GPU有ROCm,华为昇腾NPU有CANN,还有各类做推理加速的NPU厂商,每家都有自己的运行时。最直接的麻烦是,上层业务代码不可能为每种设备各写一套,底层平台也不可能为每种设备各维护一套独立的调度和监控系统。
我当初接手这个事的时候,平台里有三个品牌的GPU和两种NPU,每类设备的接入方式都不同。有的走PCIe直通,有的需要厂商提供的用户态驱动,有的设备不支持SR-IOV,有的支持MIG但版本受限。早期的做法是每种设备单独用一套脚本加定时任务去管,显存不够就人工调,设备掉卡了也是人工重启。机器数量少还能扛,一旦上了规模,光设备发现和状态同步就能把人累死。后来决定要做一个统一的算力管理底座,目标很明确:业务方只需要声明要多少算力、什么类型的设备,平台自动完成设备分配、环境注入、监控接入和故障回收。
异构统一管理这件事,本质上是把设备差异尽量隔离在下层,向上层暴露一套相对一致的资源语义。这个思路和操作系统屏蔽硬件细节是一个道理:上层应用不关心你用的是SATA还是NVMe硬盘,只关心文件读写这个接口。算力平台也一样,业务不关心你底层是A100还是昇腾910B,只关心能不能申请到卡、能不能正常跑起来、性能够不够。
1.2 统一管理的分层抽象:物理层、资源层、调度层
我倾向于把整个体系分成五层来做,从左到右依次是物理设备层、驱动运行时层、资源抽象层、调度编排层、业务接入层。
物理设备层就是那些真实的GPU和NPU板卡,包括PCIe、NVLink、RoCE网络这些互联设施。这一层要做的是设备发现、固件管理、健康检查,出了问题要能第一时间定位到具体物理位置。驱动运行时层是把厂商提供的驱动、运行时库、工具链统一封装成标准接口,尽量让上层感知不到具体厂商差异。资源抽象层负责把设备资源量化成可调度的对象,比如一张卡对应多少个单位的算力、多少显存、多少带宽,这些都是调度器要看的核心指标。调度编排层负责把任务和资源做匹配,要考虑队列优先级、亲和性、抢占策略这些。业务接入层就是用户提交作业的入口,可以是Kubeflow、SLURM、甚至是一个简单的Web界面,但底层都走同一套资源申请逻辑。
这五层每一层都有各自的难点。物理层的难点在设备种类多、兼容性杂,尤其是NPU,各家设计差异很大。驱动层最大的坑是版本冲突,一个节点上装了多套驱动,很容易把系统库搞乱。资源抽象层的难点是不同设备的算力怎么归一化,一张A100和一张昇腾910B能简单等价吗?显然不能,但平台总得给用户一个直观的参考。
我在这套体系里做的一个关键设计是引入“设备模板(Device Profile)”概念。每种设备都有一份模板,里面记录了设备名称、显存大小、算力参考值(以某款主流GPU为基准折算)、支持的特性(比如是否支持显存切分、是否支持MIG)、默认驱动版本等。这样调度器在匹配资源时,不是直接拿设备型号做字符串匹配,而是拿模板里的属性做条件匹配,灵活很多。
1.3 为什么现成的调度框架不够用,还要自己做一层
很多人一上来就问我:K8s不是有Device Plugin机制吗?直接用不就好了?K8s的Device Plugin确实能解决设备上报问题,但它只解决“设备能不能被调度”的问题,解决不了“业务怎么选设备”和“异构资源怎么共享”的问题。
举个例子。K8s默认的调度器看到的是扩展资源(Extended Resource),比如nvidia.com/gpu这个资源名,它只关心数值上够不够,不关心你调度到哪张卡上会影响数据搬移开销。如果你有NVLink拓扑和多机RoCE互联,默认调度器完全无视这些信息,结果就是任务可能被分配到跨NUMA的设备上,性能下降一截还找不到原因。另一个问题是K8s默认粒度过粗,要么给整张卡,要么不给,不支持一张卡上多个进程共享,显存利用率上不去。很多推理场景一张卡只用了2G显存,剩下30G全浪费,这在生产环境是没法接受的。
所以我们自己开发调度层的时候,并不打算重造一套资源管理轮子,而是在K8s基础上做增强。具体来说是在调度器旁边加了一个算力调度组件,负责处理设备拓扑感知、共享调度、抢占和优先级,K8s继续负责Pod生命周期和基础调度。这样既复用K8s的成熟能力,又能解决异构算力特有的问题。
2. 异构设备的接入与统一标识
把设备接入平台其实是个脏活累活,尤其设备种类一多,你会发现很多意外情况。这个章节我讲得细一点,因为能不能把基础打牢,直接关系到上层调度和监控的效果。
2.1 驱动与运行时:让GPU和NPU先“说同一种话”
驱动安装是第一步。英伟达的驱动安装相对标准化,apt装或者runfile装都有人用。AMD的ROCm对内核版本更敏感,装完还得验证一下用户态工具链是否正常。NPU就更麻烦一点,有的只支持特定内核版本,有的是把驱动放在容器镜像里,需要init容器先加载。我建议在节点接入平台之前做一个自动化巡检脚本,把设备的PCIe信息、固件版本、驱动版本、用户态工具链版本全部采集到一个统一的设备台账里。
一个很重要的细节是驱动版本与容器内CUDA版本的匹配。K8s里跑AI任务基本都走容器,但容器里的CUDA版本未必和宿主机的驱动兼容。英伟达官方给过一张兼容矩阵表,比如某几个CUDA版本需要的最小驱动版本是多少。昇腾那边也有类似的版本适配要求,CANN版本和驱动版本必须配对,配错了直接启动失败,日志还特别隐晦。我建议在节点的设备插件里加一个“环境预检”逻辑,容器启动前先检查驱动版本是否满足请求,不满足就直接拒掉,这样比跑到一半再炸要好得多。
2.2 设备插件与拓扑感知:让K8s“看见”每张卡
K8s Device Plugin的工作方式是每个节点上的插件进程向kubelet汇报自己支持哪些设备,kubelet把这些设备作为扩展资源记录下来。调度器在调度Pod时,检查节点上扩展资源的可分配数量,够就调度上去,然后kubelet在容器启动时把设备列表通过环境变量传给容器运行时。
这套流程本身不复杂,但异构场景下有两个坑。第一个坑是裸设备列表的更新时机。设备可能被占用了、被释放了、掉卡了,插件每次对账都要把最新状态同步给kubelet,如果同步不实时,会出现超额分配或者资源泄漏。我会让插件定期扫描设备状态,并且在每个设备状态变化时主动发消息通知kubelet。第二个坑是设备设备和拓扑信息没有关联。K8s只需知道节点有多少“张”卡,不关心这些卡在哪个PCIe switch上、是否共享同一个NVSwitch、和网卡的位置关系如何。但这些信息对高性能分布式训练至关重要。
为了补上这个缺口,我参考NVIDIA的拓扑感知调度思路,在节点上做了一个Topology Manager。它通过读取PCIe拓扑、NVLink信息和网卡NUMA节点信息,生成一张节点级的“设备拓扑图”,然后把这个拓扑图作为节点标注吐给调度器。调度器在分配多卡任务时,优先选择拓扑上彼此互联带宽高的卡,比如同一NVSwitch下的四张卡,尽量避免跨PCIe Switch通信。
2.3 用标签和扩展资源统一业务视角
设备接入之后,要让业务方能清晰描述自己的需求。我建议不要直接用nvidia.com/gpu这种厂商相关资源名作为用户接口,而是定义一套中性的资源语义,比如“算力单元”和“显存单元”,然后把物理设备映射到这些抽象资源上。这样用户不用关心底层是哪个厂商的设备,只需要说“我要多少算力、多少显存、是否需要多卡通信”。
同时在K8s节点上打几组标准标签。第一组是设备类型标签,比如accelerator=GPU或accelerator=NPU,还有具体型号,比如gpu-model=A100-NVL-40G。第二组是设备能力标签,比如支持显存切分、支持MIG、支持RDMA、支持RoCE等。第三组是归属标签,例如属于哪个资源池、哪个部门、哪个项目。调度器的策略可以直接基于标签做匹配,比如“训练任务必须调度到支持RDMA的GPU节点上”,“推理任务优先调度到NPU节点上”。这些标签最终会体现在K8s的NodeSelector或节点亲和性配置里。
统一标识还有一个重要用途是成本核算。不同异构设备的采购价差很大,如果只按“一张卡”来计量,成本中心根本对不上账。我们把每一类设备定义了一个“算力系数”,比如一张昇腾910B折算成多少个标准算力单元,一张A800折算成多少个,然后成本按算力单元去核算。这样业务方申请资源时能直观看出自己用掉的是贵资源还是便宜资源。
3. 统一调度与任务编排实战
调度的核心不是把任务放到某个节点上就算完事,而是要在满足约束的前提下,让资源的利用率更高,让任务跑得更快更稳。这一节重点讲我这边调度器的实现思路和一些实操细节。
3.1 三层调度策略设计
我把调度策略拆成三层:全局调度、节点调度、设备调度。
全局调度层做的事情是选择节点,需要综合考虑节点上可用资源总量、当前队列里的任务优先级、节点的健康状态、节点的亲和性约束。这一层可以复用K8s默认调度器的NodeFilter功能,但我会额外加一些自定义过滤逻辑,比如排除处于“维护模式”的节点、排除GPU故障率过高的节点等。节点调度层处理的是同一个节点内部的任务放置问题。这一步要看任务请求几卡、是否要共享卡、每卡要多少显存。如果多卡任务是模型并行训练,还要尽量把它们放在同一个拓扑域内,减少数据搬移开销。设备调度层是最后一步,真正决定这个任务具体用哪几张卡,以及卡上的显存怎么分配。
这三层策略的执行顺序不能乱,而且每一层的决策结果要记录下来,方便后面做故障定位和调度性能分析。我在这套系统里给每个调度请求生成一个“调度轨迹ID”,从进入调度队列开始到最后分配完成,每步都打日志,排查“任务为什么被调度到某张卡上”这类问题时会特别有用。
3.2 GPU与NPU混合训练的调度示例
异构调度的典型场景就是混合训练,比如一个大模型的主参数放在GPU上训练,某些子模块在NPU上做推理或向量检索。还有一种情况是GPU集群快满了,临时把一部分小任务调度到NPU上去,后续再迁移回来。这类场景在调度器里要特别关注两个点:一个是设备之间的通信路径,GPU和NPU如果在同一个节点上还好说,但如果跨节点,就要考虑是否走RDMA,以及网卡的带宽是否够用;另一个是设备模板的匹配,NPU的算力参考值低于GPU的话,调度器要把任务做拆分或降配。
我举一个实际的调度配置示例。假设有一个训练任务请求1张GPU和2张NPU,调度器的匹配逻辑大致是这样:
apiVersion: scheduling.example.io/v1 kind: DeviceRequestTemplate metadata: name: mixed-train-template spec: devices: - type: GPU model: A100-NVL-40G count: 1 shared: false needs: [RDMA] - type: NPU model: Ascend-910B count: 2 shared: false needs: [RDMA] topologyPolicy: placement: sameNode preferred: sameSwitch这里的配置我把它拉成了模板形式,调度器在处理时第一步筛选同时装有GPU和NPU的节点,第二步看这些节点上有没有满足条件的空闲卡,第三步检查节点间网络是否满足RDMA要求。如果第一步没有节点满足,就降级为“同交换机不同节点”的拓扑策略,并给任务打上一个“跨节点通信”的标注,方便用户知道数据链路会有额外开销。
实际运行中我们发现,混合训练的性能瓶颈往往不在GPU或NPU本身,而在跨设备的数据交换层。GPU和NPU之间的数据格式不一定一致,转换过程会消耗大量CPU和内存带宽。所以在调度配置里我还加了一个“设备亲和性权重”参数,如果任务里包含需要高频交换数据的设备,调度器会尽量让它们落在同一个节点或同一个NUMA域。
3.3 共享、切分与超卖:算力利用率的进一步挖掘
一个不太好看的数据是:很多推理任务在用GPU的时候,显存占用率不到50%,算力利用率更低。所以如果平台总把整张卡分给一个任务,资源的浪费是很惊人的。我们做了三层优化来提升利用率。
第一层是显存共享。对支持MIG或vGPU的GPU设备,我们可以把单卡切分成多个实例,每个实例拥有独立的显存和算力隔离。对设备支持不太好的场景,我们做的是时间片共享,多个进程共用一张卡,通过调度器控制时间片分配。这里要特别注意任务隔离问题。如果两个任务共用一张卡,其中一个跑满算力,另一个性能就会明显抖动。为避免这种情况,我会把共享任务按照“算力需求高但显存需求低”和“显存需求高但算力需求低”做配对,尽量错峰。
第二层是显存的动态分配。不是所有任务都会一直占用整张卡的显存,但驱动一般会预留整卡显存。我们做了一层显存代理,在用户态把任务实际内存使用量上报给调度器,空闲显存可以预分配给其他任务用。这里要设计一个显存水位回推机制,防止突然大量申请导致现有任务OOM。
第三层是超卖,但这里要非常谨慎。超卖确实能提升资源利用率,但一旦任务峰值同时出现,节点负载会瞬间拉满甚至OOM。生产场景下我一般建议按1.2到1.5倍做超卖,同时开启节点级的内存流控和GPU算力配额限制,保证出问题的时候影响面可控。
4. 监控、可观测性与故障排查
统一管理的最后一个硬骨头是监控。每种设备都有自己的监控工具和技术栈,比如GPU常用DCGM,昇腾有自带的管理工具,但它们的指标格式、采集方式、告警字段各不相同。如果不能把它们归一化,运维手里光是各种命令行查状态的工具就有好几套。
4.1 设备监控采集的标准化
我这边在设计监控系统时,没有直接对接厂商的监控API,而是在各节点部署了一个异构采集代理。这个代理负责把厂商提供的工具输出统一转换成标准的Prometheus指标格式,同时把设备健康状态同步到K8s自定义资源里。这样上层Grafana、告警系统只认一套数据模型,不用关心底层是哪个厂商的设备。
指标采集要分两层。第一层是设备级指标,包括利用率、显存用量、温度、功率、PCIe错误计数、NVLink带宽等,这些指标用来判断硬件是否健康。第二层是进程级指标,包括每个容器使用了哪些设备、显存占用多少、算力使用多少,这些数据用来做任务级别的分析和计费。进程级指标要特别小心,因为同一张卡可能被多个容器共享,采集时要通过卡上的进程信息和容器ID做关联,否则归因会乱。
我在采集层加了一个“指标质量门禁”机制,每个指标都带元数据,标注采集方式、采集周期、可能误差。这样下游在画图或告警时能明确知道某个指标是估算值还是精确值,避免被一个不准确的指标误导。
4.2 任务级监控与失败重试机制
设备监控只能告诉你设备是否健康,但用户更关心的是任务跑得怎么样。因此我们又做了一层任务级监控。每个训练或推理任务在启动时,监控系统会把任务的容器ID、使用设备列表、启动时间等关联起来,形成任务维度的数据视图。用户在界面上能看到这个任务当前的算力利用率曲线、显存变化曲线、迭代耗时趋势等。
真正坑人的是设备掉卡问题。训练大模型跑到一半,某张卡掉了,如果没有检查点续训机制,前面几天的算力时间就全白费了。我们在调度器里做了一个“重调度+检查点联动”机制,任务失败时先判断是否要自动重试,如果有检查点,就通过环境变量把检查点路径注入新容器,自动从最近的一个保存点续跑。这里有个细节:重试前要把出问题的节点做新的健康检查,如果确认是硬件故障,就立刻把该节点上的设备从资源池里摘除,避免下次再调度上去。
4.3 常见故障速查与排查技巧
这些是我们在实际运维过程中遇到频次比较高的问题,整理成一个速查表,可以直接照着排查。
| 故障现象 | 可能原因 | 排查方法 | 处理建议 |
|---|---|---|---|
| 容器启动报CUDA版本不匹配 | 驱动版本与容器内CUDA版本不兼容 | 检查驱动版本和容器内nvidia-smi输出 | 更新驱动或替换兼容的基础镜像 |
| NPU设备在容器内不可见 | 设备插件未将设备映射进容器;CANN环境变量未注入 | 检查Device Plugin日志和容器内device列表 | 确认设备插件环境变量注入逻辑 |
| 多卡任务性能远低于预期 | 未考虑NVLink/PCIe拓扑,跨Switch通信 | 用nvidia-smi topo查看拓扑;检查调度亲和性 | 优化调度器拓扑策略,增加亲和性约束 |
| GPU显存泄漏,长时间运行后OOM | 推理框架缓存未及时释放 | 观察显存曲线,定位泄漏进程 | 配上限流和定期重启策略 |
| 节点偶发掉卡但物理设备正常 | PMC/驱动误报或固件Bug | 查看dmesg和厂商事件日志 | 升级固件或调整驱动参数 |
| 异构设备混跑时任务互相干扰 | 共享卡上算力争抢 | 查看进程级指标,分析时间片分配 | 对低优先级任务做算力配额限制 |
排查这类问题有个通用思路:先看节点层,再看驱动层,最后看业务层。节点层看电源、散热、PCIe链路是否异常,驱动层看驱动日志、事件上报、环境变量是否有误,业务层看框架版本、CUDA依赖、算子实现。不管什么问题,建议先把所有日志落到一个统一的日志平台,否则设备和节点一多,靠ssh到每台机器上翻日志根本查不动。
5. 一些实操心得和后续还可以做的事
文章写到这,主体内容基本讲完了。最后分享几个我在实际跑这段平台时的个人感受。
第一点,异构算力管理不是一次性把事情做完就算完。设备的型号会更新,厂商的驱动和用户态工具链会更新,K8s本身也在快速迭代,平台要保持一套平滑升级的机制。我建议分层升级,先升级设备插件和采集代理,再升级调度组件,最后批量升级节点设备驱动,避免一次性全量变更导致风险不可控。
第二点,尽可能做一些设备灰度验证。新到一批卡、换一个新驱动版本,不要直接全量接入生产环境。用一批测试节点先跑几天压力测试,把错误率、温度、性能衰减曲线跑出来,确认稳定后再纳入正式资源池。这个习惯能帮你省掉大把救火时间。
第三点,文档和台账一定不要省。异构环境的设备太多,型号、序列号、驱动版本、固件版本、所在节点、所属项目这些信息,记录在一张能检索的表里,排查问题的时候能少花很多时间。哪怕只是简单的Google Sheet或者Wiki页面,都比没有强。
后续我觉得还可以做的方向是自动化的性能基准库建设。现在异构设备越来越多,不同设备在不同模型、不同算子上的表现差别很大,如果有一套相对完整的能力基准库,调度器在决策时就可以根据任务类型自动推荐最合适的设备型号。这个做好了,整个平台的算力分配会更智能,也算是从“能统一管理”迈向“管得好、用得好”的关键一步。