PaaS 平台 GPU 资源复用与超卖探索

背景&动机

PaaS平台用户使用上的需求:

  • 部分推理服务的实例占用的资源较少,即使只占用1卡也存在浪费,因此希望将多个推理服务的实例部署到同一个GPU上,从而节约成本。
  • 由于租户内资源有限,往往只有一两个节点,但有十几个用户的开发机。因此希望支持多个用户的开发机共用一个节点的GPU。

PaaS平台侧从提高集群利用率、增加收入和毛利率的角度出发,也存在下面的需求:

  • 通过对未分配/已分配空闲GPU超卖进一步增加收入
  • 在节点GPU资源全部调度分配后,以CPU任务的方式继续超卖节点的CPU和内存资源

为了满足用户需求,需要支持GPU复用功能,吸引用户使用平台,同时为了提升集群资源利用率和毛利率,需要进一步探索闲时复用方案,对于已经售卖但处于空闲状态的资源进行超卖。

本文将介绍PaaS侧在解决上面的问题过程中,在GPU资源的复用与超卖方面的一些探索。所谓复用或者超卖,都建立在把同一个GPU同时分配给多个实例的基础上,属于一种GPU共享技术

  • GPU共享一般是指在多个用户或应用程序之间共享GPU资源的技术。这种技术通过虚拟化、资源管理和调度算法,使多个任务能够在同一GPU上运行,从而提高GPU的利用率。
  • GPU共享在大模型训练中的用处不大,因为大模型训练往往都是整节点分配资源,所以它的应用场景主要是在推理、开发机

市场现有方案对比

方案类型 代表方案 局限性
厂商官方解决方案 Nvidia MIG、Nvidia MPS、Nvidia vGPU 授权成本高;使用场景局限性大;不好灵活配置;不利于整合不同GPU厂商功能;不利于扩展超卖等业务功能
基于API劫持的隔离共享方案 用户态vCUDA、OrionX,内核态cGPU 有侵入业务层;开发维护难度大;存在性能损失;整套复杂系统ROI不符合预期
轻量开源调度共享方案 Aliyun gpushare-device-plugin、gpushare-scheduler-extender 依赖调度逻辑变更;与已有体系存在冲突;不利于进一步扩展

我们的选择:自研轻量化共享方案

在K8s原生的框架和现有的调度体系内,基于自定义的 Container RuntimeDevice Plugin节点资源管理组件 等,实现一种通用的容器间的GPU设备共享。

核心优势

  • 对调度逻辑的影响更小
  • 设备类型无关,更加通用且便于扩展
  • 核心功能:支持将一块卡同时挂载到多个容器里

从容器间共享开始

Dind的问题

目前线上开发机的Dind(docker in docker)功能,是通过在开发机pod中设置了一个运行docker服务的Dind容器实现的。

问题:用户常常会有需要在 Dind 中使用 GPU 的需求,但是由于 K8s 中资源是以容器为单位进行调度的,不同容器之间的资源互不相干,一个 GPU 设备要么分配给主容器,要么分配给 Dind 容器。当前,如果开发机开启了Dind功能,在创建 pod 的时候是将所有GPU都挂载给Dind容器的,这就会导致用户在主容器中看不到显卡,也没法使用显卡。

解决思路

虽然 K8s 不能给不同容器分配同一个 GPU,但是从容器的角度来讲,同一个 GPU 是可以被不同容器挂载的,因此如果从更底层去实现,是完全可行的。

问题转化

  1. 怎样从底层把一个容器中的设备同步挂载到另一个容器中?
  2. 在哪一步/哪一个组件中去完成上面的同步挂载的操作?

容器创建 Hacking

K8s容器创建基本流程:

1
kubelet → containerd → spec文件(OCI标准) → runc → 容器
  • containerd 在创建容器时会根据kubelet请求中的信息(如 Pod 的配置、容器的镜像、环境变量、挂载卷等)生成容器的规范文件(spec 文件)
  • 这个 spec 文件符合 OCI标准,描述了容器的配置和运行时环境
  • runc 实际创建容器时就是参考这个spec文件进行创建的

关键发现:只要将容器A的spec中的设备挂载信息拷贝到容器B的spec中,就可以实现将同一块GPU同时挂载给容器A和容器B。

自定义Runtime方案

查看 nvidia-container-runtime 的源码,会发现它定义了很多 spec-modifier,用于修改容器的 spec 文件,在修改完成后,最终再调用 runc 去创建容器。

我们的方案:仿照 nvidia-container-runtime “包一层"的方式,自定义runtime"再包一层”,在真正创建容器之前修改它的spec文件,将另一个容器的设备、挂载、环境变量等信息同步到当前容器里。

用annotation传递共享关系

利用容器的 annotation 作为接口:

1
2
3
4
5
6
7
"annotations": {
...
"gpushare.k8s.io/container-devices-from.dind": "_/_/main",
"gpushare.k8s.io/partition": "po-xxxxxxxxxxxx",
"gpushare.k8s.io/user_id": "ac-xxxxxxxxxxxx"
...
}

annotation格式说明

  • "gpushare.k8s.io/container-devices-from.dind":代表需要进行设备同步,接收设备的容器名称为 dind
  • "_/_/main":代表来源容器的信息,2个下划线分别代表来源容器属于当前namespace和当前pod,main 代表容器名称
  • 这样的设计为以后租户间、pod间的显卡复用提前留好接口

Runtime执行逻辑

  1. 首先对annotation进行检查
  2. 如果没有检查到上述的annotation,就什么都不做
  3. 否则就将来源容器的设备挂载信息拷贝到目标容器的spec中,再调用下一层的runtime创建容器

小结

  • 上层组件在创建pod的时候,只需要向pod中写入上述格式的annotation,就可以完成main容器与dind容器之间的设备共享
  • 这种容器间的设备共享不局限于特定的GPU设备类型,对所有类型的GPU设备都可以生效,是一种比较通用的实现方式

向Pod间显卡复用拓展

新的共享需求

基于在容器间共享GPU的能力,支持推理服务和开发机GPU共享的需求。

以推理服务为例

  • 最初需求:支持1卡规格的推理服务实例两两共享GPU
  • 用户在创建推理服务时,不需要指定共享的对象,只增加一个"显卡复用开关"

带来的问题:如何区分和处理第一个开启共享的pod?因为基于runtime的设备同步的共享机制永远需要一个"来源容器"。

ShadowPod和虚拟设备

由于在k8s的框架里无法将同一张卡同时分配给两个pod,为了实现上述需求,并且最小化对已有调度逻辑的侵入,设计了一种基于 ShadowPod虚拟设备 的共享方案。

核心概念

概念 说明
ShadowPod 隐藏的pod,在系统中仅在后台可见,作用是创建出用来占住GPU资源,作为设备同步中的"来源容器"
SharePod 开启了"显卡复用"开关的推理服务pod,作为"目标容器"接收设备同步
虚拟资源 gpushare/share-gpu,在k8s的调度框架内实现对应关系

工作流程

1
2
3
4
5
1. 根据SharePod的需求,动态创建ShadowPod
2. 根据ShadowPod生成对应的虚拟设备
3. 虚拟设备产生后,供SharePod调度使用
4. SharePod创建时,底层runtime把ShadowPod中的显卡设备同步到SharePod之中
5. 实现SharePod之间的GPU共享

探索进一步超卖

扩大spot任务的活动范围

当前spot任务机制

  • 一种服务质量不被保证的低优先级任务
  • 在运行过程中随时可以被高优先级的reserved任务驱逐抢占
  • 可调度范围:集群中所有空闲未分配的资源(未售出的 + 已售出但暂未分配的)

问题场景

某个用户以包年包月的方式购买了一个8卡节点,同时起了一个8卡的开发机在上面,但是这个开发机只有白天工作时间会运行一些GPU任务,大部分休息时间则保持空闲,GPU利用率为0。我们能否将这部分空闲的GPU时也作为可以调度的资源超卖呢?

"偷卡"机制设计

核心思路

  • 利用已有的"同一块GPU挂载给多个pod"的能力
  • 准确判断用户的"离开"状态 → 把开发机中的GPU重复挂载给其他spot类型的pod
  • 检测到用户的"回归" → 立刻驱逐偷卡的spot任务,释放显卡资源

用户体验目标:让用户感觉离开的这段时间好像什么事情都没发生一样。

如何判断用户离开与回归

技术原理

"回归"意味着对 GPU有所操作,无论是用户执行一个torch脚本,还是执行一个 nvidia-smi命令,最终在系统层面上都会转化成对GPU设备的系统调用

实现方案

1
2
3
4
5
6
7
8
9
10
11
参考audit、falco等监控方案 → 在内核中劫持对GPU设备的ioctl系统调用



解析ioctl系统调用的参数:
- 通过参数中的设备魔数判断当前访问的设备是否是GPU设备
- 通过pid和pidnsid信息判断容器身份



实现对容器中GPU访问的监控和统计

判断逻辑

状态 判断条件
离开 容器中超过一段时间没有对GPU设备的访问,并且设备利用率在过去这段时间一直是0
回归 处于"离开"状态的容器中出现了对GPU设备的访问

技术演进:eBPF → 内核模块

问题:当检测到"回归"事件并立刻驱逐偷卡的负载,还是来不及。因为负载的驱逐需要时间,显卡资源的释放也需要时间。

解决方案

  • 在内核中检测到用户回归的同时,将其系统调用阻塞一小段时间,直到显卡资源彻底释放为止
  • 由于eBPF技术的限制无法进行阻塞操作,最终将内核逻辑重新实现为一个内核模块

新的spot调度视图

引入"偷卡"机制后,为spot任务引入新的虚拟资源类型:gpushare/spot-gpu

资源映射关系

1
真实GPU ←→ gpushare/spot-gpu (一一对应)

状态流转

场景 真实GPU状态 gpushare/spot-gpu状态 行为
reserved任务分配 已分配 Unhealthy 不可用,如有spot负载则驱逐
开发机"离开"状态 已分配但空闲 Healthy 可分配给新的spot负载
用户"回归" 需要使用 触发驱逐 内核模块信号→驱逐spot负载

CPU和内存超卖

数据处理任务超卖

背景:当前节点上CPU、内存和显卡资源是捆绑成固定的规格统一分配的,在节点上所有资源按照规格分配完毕后,理论上就不能再调度其他CPU规格的任务到节点上了。

方案:通过引入虚拟资源 vCPUvMem,实现将数据处理任务调度到已经全部分配的节点上,以此实现CPU和内存的超卖。


技术架构

通用性和可扩展性分析

当前容器间设备共享是基于底层runtime实现的,具有很好的通用性和可扩展性。

业务需求扩展示例

场景:节点上只剩1卡空闲,另外还有一个1卡的处于"离开"状态的开发机,此时启动一个2卡spot负载。

方案:意味着它需要同时接收来自另外2个容器的GPU设备,只需要扩展上文runtime的annotation接口即可。

国产芯片适配

优势:底层runtime进行容器间设备同步的机制是设备无关的,适用于所有类型的设备。

已适配芯片

  • 国际:NVIDIA、AMD
  • 国产:燧原、摩尔线程、天数、沐曦、壁仞等

扩展方式:对于后续可能新增的芯片类型,可以通过增加配置项,而不用修改代码来实现。


🎉 总结

1
2
3
4
5
6
技术演进路线:
容器间共享GPU

Pod间的GPU共享

空闲资源的时分复用超卖

当前成果

  • 随着需求的导向,基于容器间设备同步这一技术对PaaS中GPU资源的复用与超卖进行了一系列探索
  • 当前方案在产品、研发和测试同学的共同努力下,已经能够稳定地在线上环境运行

后续规划

  • 完善可观测性等产品功能和技术实现
  • 不断探索其他更多的调度优化方式,以实现集群利用率和平台效益的最大化

📋 文档信息:PaaS中GPU资源的复用与超卖探索