国产GPU虚拟化实战:8张卡撑起30个开发环境
2026/9/24 21:29:47 网站建设 项目流程

8张卡撑起30个开发环境,这个比例第一眼看上去有点像在做资源魔术。我们去年在电科云内部搭AI研发平台时,就因为这个数字被团队内部挑战过好几轮:国产GPU本来就紧张,怎么还敢把一张卡拆给四五个环境用?

先说结论:能撑起来,前提是搞明白开发环境的真实GPU占用画像,再引入一套靠谱的GPU虚拟化调度组件。我们最终选择的是HAMi,这套方案让30个容器化开发环境(VS Code Remote、Jupyter Notebook、模型调试任务等)稳定跑在8张国产加速卡上。整件事不是简单的“一张卡切成四份”,涉及硬件适配、设备注册、调度策略、显存隔离、算力限制一整套链路。

如果你正在做内部AI平台、K8s上的GPU资源池,或者被老板问“为什么30个人只有8张卡还能干活”这类问题,这篇文章应该能给你一个完整的参考答案。文章里所有内容来自我们实际环境整理,涉及版本相关的参数我会特别说明,不同版本之间差异不小,直接照搬配置前一定要先核对。

1. 先算清楚账:为什么30个开发环境只需要8张卡

1.1 开发环境和训练环境的GPU画像完全不同

我们一开始也想当然按“人均一块GPU”规划,但实际观察团队工作流后发现,这个假设根本不成立。算法工程师一天里的状态大概是这样的:早上改模型结构,改完跑一个step验证一下,显存占用16GB以下,持续几分钟;然后去改数据加载、看loss曲线、调超参,这段时间GPU基本空转;只有到下午要正式微调或评估时,才会连续占用一块卡几十分钟甚至几小时。

如果把工作流拆开看,不同活动的GPU需求差异非常大:

开发活动GPU主力程度典型显存占用持续时长能否共享
写代码/改配置几乎不用0完全能
单步调试/小batch验证2-16GB分钟级
数据预处理与可视化间歇性
模型转换/量化/编译低到中时高时低部分能
正式训练/大规模推理基本吃满不能切太碎

结论很清楚:30个环境即使同时在线,同一时刻真的在“吃”GPU的往往只有三分之一不到,而且就算在吃,也不是所有算力都用满。按这个画像,8张64GB的卡共512GB显存,每个开发环境给12-16GB,理论上就能覆盖30个环境。

1.2 算账的具体数值

我们来把数字摆到台面上算一遍。总共512GB显存,30个环境,如果平均每个分配16GB,那就占掉480GB,剩下的32GB留给系统开销和缓冲。这个数字看起来紧,但配合算力配额再看就合理了:每个环境给20%左右的算力配额,30个环境的“总需求”虽然超过8张卡总算力的600%,但因为实际使用时高度错峰,调度器可以在某张卡空闲时让其他环境临时用满。

这里要特别提醒一句:不能把账算到极限。我见过不少团队一上来就追求高密度,结果一个环境出问题把整张卡拖死,连累所有共享用户。我们内部定的标准是虚拟化池的显存超卖率不超过1.2倍,算力超卖率不超过2倍,极端并发时宁可让低优先级环境排队,也不能把整卡打爆。

1.3 满足可落地的三个硬条件

光算账不够,要真正让“8卡承载30环境”落地,必须满足三个硬条件:

  • 条件一:显存强隔离。开发环境都是人在操作,经常出现显存泄漏、一次性申请超大显存的情况。一个环境把显存打爆,不能影响同卡其他环境。
  • 条件二:细粒度切分与动态回收。环境随时创建和销毁,显存要能按MB级粒度划分,空闲环境退出后资源要能立即归还,否则碎片化很快让整个资源池瘫痪。
  • 条件三:算力不能无限制。否则一个跑10个并行DataLoader的环境,能把整张卡的AI Core拖垮,旁边几个环境全部跟着遭殃。

这三个条件直接把“物理独享、纯时间片、硬件MIG”这些方案筛掉了,HAMi恰好就是冲着这三个条件去的。

2. 为什么选 HAMi:不是唯一解,但是最合适的解

2.1 先排掉四个常见方案

物理独享就不用多说了,8张卡只能给8个环境,30个环境就是3倍缺口,哪怕靠排队也严重影响开发效率。这个方案在任何资源紧张的场景下都不现实。

官方时间片方案也有明显问题。NVIDIA官方device plugin这类方案主要解决的是“算力复用”,显存还是不隔离的。一个容器里cudaMalloc 30GB,整卡显存被打满,同卡其他环境立刻OOM。放到有研发团队的环境里,出现一次就会被骂死。而且国产卡这边,官方方案的适配程度参差不齐,很多甚至没有官方插件可用。

硬件切分(MIG、vNPU这类)隔离性确实好,但问题同样不少。很多国产卡不支持硬件切分,或者需要通过专门的配置工具才能做;另外硬件切分的档位往往是固定的,比如只能切成1g、2g、4g,和开发环境那种“又要5GB又要15GB”的灵活需求完全对不上。在K8s上动态管理这些硬件实例更是别扭。

还有一种是纯应用层共享,比如在torch里通过环境变量控制几个进程各自用卡,这种方式断代严重,无法按环境精细化分配,K8s调度器也感知不到资源变化,只适合单机调试,不适合平台化。

2.2 HAMi 到底是个什么东西

如果要用一句话讲清楚:HAMi是一个面向K8s的异构AI计算设备虚拟化中间件,它把驱动之上的Runtime调用管起来,让“一张卡按显存、按算力切成很多份”变成K8s的原生能力。

组件结构上,HAMi主要管三块:device plugin负责设备发现与注册,scheduler extender负责在调度阶段做资源打分与选卡,runtime钩子负责容器启动后的显存和算力隔离。三块配合起来,对上层训练框架几乎透明。

最让我看重的是它对多厂商卡的支持。HAMi不只是支持NVIDIA,还支持昇腾、寒武纪等一系列国产加速卡,这对我们这种已经切换到国产GPU平台的团队来说太重要了。不用为每种卡单独维护一套虚拟化方案。

2.3 方案对比,为什么落定在 HAMi

方案隔离粒度是否依赖硬件显存隔离算力控制K8s融合度多厂商支持
物理独享整卡自然隔离一般
官方共享插件进程级一般
硬件切分硬件实例
HAMi显存/算力原生融合

综合看,HAMi胜在软件层虚拟化、无硬件依赖、切分粒度细,而且天然长在K8s调度器上。对我们这种“资源池统一管理、环境生命周期短、开发环境为主”的云平台来说,它是最贴合的选择。

2.4 不回避HAMi的局限性

选择HAMi不等于它能解决所有问题。它的显存隔离本质上是“记账式”的,不是硬件强隔离,如果业务代码绕过Runtime API直接操作设备,隔离就可能失效。算力限制的颗粒度也取决于不同厂商的Runtime实现,不一定能像硬件MIG那样做到严格配额。

所以我们内部定了一条原则:HAMi只承载开发调试和轻量推理,正式训练预留整卡池,绝不进虚拟化池。这个认知在项目初期就得统一,不然后面很容易出事故。

3. 硬件归一与驱动适配:国产GPU接入K8s的第一道坎

3.1 设备层准备:不是插上卡就能用

国产加速卡和普通显卡的接入方式很不一样,不是插上就能被K8s调度。我们拿到的这批卡,需要先做一套完整的设备层准备:装驱动、刷固件、装Runtime库。以昇腾卡为例,驱动和固件版本必须和AI芯片型号严格匹配,同时还要装CANN或者NNRT Runtime库,上层训练框架才能正确调用加速能力。

这里有个最常见的坑:只装驱动没装Runtime,或者CANN版本和PyTorch适配不一致,导致容器里初始化设备直接报错。我们最初的排错时间,有三分之一都花在这些“看起来不是问题”的问题上。

我们的解决办法是把Runtime固定成一个base镜像,所有开发环境的镜像都从这个base构建。这样不管研发团队怎么折腾上层依赖,底层驱动接口是不变的,出问题也只在base镜像层面解决,不会每次都在几十个环境里各自排查一遍。

3.2 K8s里如何看到卡

设备层就绪之后,需要让K8s感知到这些加速卡。这一步依赖设备插件(Device Plugin):插件通过标准接口把节点上的卡上报给kubelet,kubelet再把它注册为节点可调度资源。执行完这一步,kubectl describe node就能看到类似昇腾资源、nvidia.com/gpu这样的资源项了。

注意,设备插件的资源名由厂商或HAMi决定,不同版本之间可能不一样,我们曾经因为升级后资源名变了,导致一整批存量Pod无法被调度。这块一定要在文档里记录清楚。

同时要对节点打上标签,把卡型、显存容量、驱动版本都标出来,比如gpu.model=atlas-910gpu.mem=64G。调度时通过nodeSelector和亲和性配置,保证任务不会跑到“卡型不支持”的节点上去。

3.3 设备注册与HAMi的接入

HAMi部署完成后会接管整个设备管理流程。设备插件通过ListAndWatch上报设备,调度器对每张卡维护一个“剩余显存/剩余算力”的账本,有新的Pod申请时,在这个账本上做扣减和恢复。

这个环节最需要注意的一点是:设备插件所在节点的驱动版本必须和节点实际安装的版本一致。如果集群节点间驱动版本不统一,设备插件的上报数据可能错乱,导致调度器以为某张卡有100GB显存,实际只有64GB。我们最终把集群所有节点的驱动和Runtime收敛到了同一版本,这个问题才彻底消失。

4. 显存切分与算力隔离的实现逻辑

4.1 两层协作模型

对一个使用HAMi虚拟化的Pod来说,从用户提交到容器运行,调度过程分两步。第一步,kube-scheduler在过滤节点时触发scheduler extender,HAMi的调度扩展组件会根据显存剩余、算力剩余、节点标签亲和性,对所有候选设备打分排序;第二步,Pod被绑到节点后,kubelet调用device plugin的Allocate接口,把选好的GPU实例转换成容器可见的环境变量和设备挂载点。

这两层合起来,决定了“任务去哪个节点、用哪张卡的哪一块”。理解了这个模型,后面排查问题就顺了:凡是调度位置不对,先查scheduler环节;凡是启动失败、资源对不上,再查device plugin环节。

4.2 显存隔离:记账式加API拦截

容器内应用调用显存分配API时,底层真实发生的事情很多人不清楚。以昇腾卡为例,应用调用类似rtMemAlloc的接口,这个调用会先经过HAMi注入的shim库。shim库维护着一张“虚拟显存账本”:如果本次分配在这个环境的配额之内,正常放行;如果超过配额,直接返回OOM错误,而不是真的让物理显存被打爆。

这种方式的本质是记账式隔离,不是硬件物理隔离。好处是分配粒度可以做到很细,灵活,不需要硬件支持;坏处是如果业务代码绕过Runtime API,比如自己直接映射显存地址空间,隔离就有可能失效。好在开发环境这个场景下,绝大多数应用都规规矩矩走Runtime API。

可以打个比方:这就像给每个房间装了独立电表,正常情况下各用各的额度,谁也影响不了谁;但如果有人私拉乱接电线,电表就形同虚设。

4.3 算力隔离:限制而不是独占

算力隔离的思路和显存隔离不太一样。它的目标不是给每个环境精确预留多少算力,而是防止某个环境把整张卡的算力打满,从而拖垮同卡其他用户。

具体实现上,不同厂商差异很大。有的通过时间片轮转,有的通过任务队列配额,有的通过在Runtime层限制算子并发。国产卡在算力隔离上普遍没有NVIDIA那么成熟,我们实测下来的感受是:能做到限制峰值,但不太能做到非常精细的优先级控制。对开发环境这种场景够用,但如果未来有严格的SLA隔离需求,还是得靠整卡池来承担。

4.4 为什么上层框架感觉不到差异

对训练框架来说,它看到的是:环境变量里写着“可用设备0”,设备文件也存在,调用Runtime API也正常,所以它以为自己在用一张完整的卡。实际上每次显存分配、每个算子提交,底层都经过了一层“翻译”。

这也是HAMi这类虚拟化方案的核心价值之一:对上层应用透明,不使用户感知到底层资源是切碎了的。研发团队的代码完全不用改,只用在自己的K8s模板里声明需要多少显存和算力就行。

5. 部署与配置实操

5.1 安装流程

安装HAMi之前,建议先把环境检查清单过一遍:K8s版本是否满足要求、节点驱动和Runtime是否就绪、kubelet的DevicePlugins特性是否开启。我们当时跳过了其中一步,后面排查浪费了不少时间。

安装本身不复杂,用Helm或直接用官方YAML都可以。大致做的事就是部署device-plugin、scheduler extender、相关CRD和webhook。以Helm方式为例:

helm repo add hami https://project-hami.github.io/HAMi/ helm install hami hami/hami --namespace kube-system --version <your-version>

这里提醒一下,具体版本号一定要以官方文档为准。我们中途升级过一次HAMi版本,配置项的语法有明显变化,旧参数直接失效了,需要在升级前仔细阅读Release Notes。

安装完成后验证也简单:kubectl get pods -n kube-system | grep hami,确认三个组件都在Running状态;然后看节点资源里有没有新增的GPU相关可调度资源。

5.2 核心配置项

每个集群规模不同,配置参数不能完全照搬官方默认值。我们根据自己的环境调整了几项关键配置:

配置项作用我们用的值
最小显存分配粒度决定显存切分的最小单元,避免过小碎片256MB
单卡最大虚拟设备数量防止单卡被切得过碎,影响运维8个
超卖开关与倍率允许超卖的幅度;开发环境可容忍小超卖1.2倍显存,2倍算力
调度策略同节点优先、同卡优先、负载均衡等负载均衡
节点排除标签把训练整卡池节点排除在虚拟化池外gpu.pool=training

这几个参数不是一次调对的。最开始我们用默认配置,结果单卡被切成十几个小碎片,单个环境的显存大小根本不够用,后来才改成限制单卡最大虚拟设备数量。这个优化对稳定性帮助很大。

5.3 开发环境接入示例

研发团队那边不用手写这些YAML,但我们平台模板的底层就是下面这个逻辑:

apiVersion: v1 kind: Pod metadata: name: dev-gpu-01 labels: app: dev-environment spec: containers: - name: dev image: registry.internal/base/pytorch:2.1-cann resources: requests: nvidia.com/gpu-mem: 16Gi nvidia.com/gpu-cpumem: 512Mi nvidia.com/gpu-coremem: 20 limits: nvidia.com/gpu-mem: 16Gi nvidia.com/gpu-cpumem: 512Mi nvidia.com/gpu-coremem: 20

资源字段名在不同vendor、不同HAMi版本里会有差异,上面示例只是我们环境里实际使用的写法。最稳妥的做法是用kubectl describe node看一下节点上真实暴露的资源名,再照着写。

我们给研发团队封装了三个档位:小环境8GB显存、10%算力;普通环境16GB显存、20%算力;大环境32GB显存、50%算力。用户只需要在平台界面上选档位,后台自动生成对应的Pod。

5.4 运维侧处理“环境创建失败”

开发环境创建失败最常用的排查顺序是:先看Pod事件,确认是不是资源不足;再看scheduler extender日志,确认调度器有没有正确计算显存剩余;接着看device plugin日志,确认分配是否成功;最后看容器本身的日志,确认镜像、Runtime相关依赖是否正常。

这条链路看起来简单,但实际排障中90%的问题都能在这个顺序里找到答案。不要一上来就盯着容器日志看,很多时候问题根本到不了容器启动那一步。

6. 压力测试与踩坑记录

6.1 我建议的四轮验证

HAMi部署完不能直接放生产,我建议至少要过四轮验证。第一轮,单容器显存上限验证:故意在容器里申请超过配额的显存,预期返回OOM,同时同一节点其他容器不受影响。第二轮,同卡多容器并发验证:在一个节点上拉起4个不同配额的环境,同时申请显存,验证互相不挤占。第三轮,批量调度压测:一次性提交30个开发环境Pod,观察调度器排队情况和最终分配的均匀度。第四轮,真实负载验证:一个环境跑LoRA微调,另外几个环境同时反复创建销毁,观察新环境的创建速度和整体稳定性。

我们当时第四轮差点翻车。LoRA微调跑起来后,新建环境明显变慢,后来发现是调度器的状态同步有延迟,并发创建时同一批Pod会争抢同一张卡的剩余配额。升级版本并加了对账机制后解决。

6.2 坑一:容器退出后显存不释放

这是上线后遇到的第一个严重问题。开发环境删除后,节点上“剩余显存”没有回升,环境利用率肉眼可见地往下掉。

排查链路是这样的:先看Pod删除事件有没有正常触发回收逻辑;再看device plugin的日志,发现它上报的剩余显存没有更新;最后确认是容器删除事件没有正确传递到设备插件,导致资源账本没有归还。解决方式是升级到修复版本,同时给平台加了一个定时核对任务,每小时拿设备插件上报数据和节点真实状态对账,对不上就触发重新上报。

这事的教训是:虚拟化组件最怕“状态不同步”,运维侧一定要有对账兜底机制,不能完全依赖组件自身的状态管理。

6.3 坑二:同一份镜像在不同节点上表现不一样

另一个印象深刻的问题是,同一个开发环境镜像在节点A创建正常,在节点B却反复CreateContainerError。一开始怀疑节点资源不足,看事件不是;又怀疑device plugin分配失败,也不是;最后翻到容器启动阶段,发现是HAMi注入的runtime钩子库路径和节点B上安装的Runtime库版本对不上,导致容器启动失败。

根因还是节点间版本不一致:节点A是新装的CANN,节点B还是旧版本,钩子库和旧版本不兼容。解决方式很简单也很粗暴,把所有节点的驱动和Runtime统一到同一版本,之后这类问题基本绝迹。

6.4 坑三:LD_PRELOAD钩子被业务环境覆盖

这个坑比较隐蔽。HAMi在做Runtime拦截时,很多时候依赖类似LD_PRELOAD的环境变量注入机制。但有的深度学习框架镜像里已经自定义了LD_PRELOAD,把自己依赖的库挂在前面,导致HAMi的钩子失效,容器里的应用直接看到“完整卡”。

排查方法是进入容器打印环境变量,看注入是否成功,再对比正常环境和异常环境的差异。解决方式是调整HAMi的注入逻辑,或者改基础镜像规范,要求业务镜像保留平台入口。这里特别提醒:喜欢自定义环境的研发团队,很容易和这种钩子机制产生摩擦,平台规范里必须写清楚。

6.5 国产卡特有的坑

最后说一个国产卡特有的坑:npu-smi info这类系统工具显示的是物理整卡信息,容器里看到的信息不代表自己实际分到的资源。研发团队如果习惯用系统工具看资源占用,很容易误以为自己拿到了整卡,实际上只是切出来的一块。我们后来统一要求大家通过平台监控面板看资源使用,禁止依赖节点上的系统工具。

另外,国产卡的CANN/PyTorch适配版本非常繁杂,不同版本组合出来的行为差异很大。我们内部直接锁死了一套版本组合,谁的镜像版本特殊就单独开整卡池,不进虚拟化池。这个规则一开始有人觉得武断,但后来是它保住了整个平台的稳定性。

7. 最后提醒:虚拟化池与整卡池要分开规划

7.1 不要把所有负载都塞进虚拟化池

这是我在整个项目里最想强调的一点。开发调试、小批量验证、推理测试适合放进HAMi虚拟化池;长时训练、大模型微调、有严格性能要求的生产推理,必须走整卡池。虚拟化的本质是提高碎片负载的调度效率,它不能替代物理资源。

我们通过节点标签把两类池子彻底隔离开,虚拟化池的Pod可以通过nodeSelector落在指定节点组,整卡池的任务则直通物理卡。后面扩容或者变更配置时,互不干扰,运维心态会好很多。

7.2 配额治理与回收

开发环境数量如果不治理,30个很快会变50个、80个,资源池再能切也扛不住。我们设置了空闲回收规则:超过一定天数没有活跃使用的环境自动释放,对应GPU配额回收给新环境用。对于研发团队来说,环境是有状态的,回收前会给足提醒和导出入口,避免误删。

资源是动态的,治理规则也要动态调整。我们每个月看一次资源使用报表,根据活跃度、平均显存占用、排队时长三个指标,决定下个月是提高配额还是收紧超卖。

7.3 监控体系

虚拟化后,节点级指标基本没有参考价值,必须按容器维度看监控:每个环境的显存使用量、GPU利用率、调度排队时长。HAMi本身就提供了监控接口,接到Prometheus后,我们把集群、节点、Pod、容器几个维度全部打通,做成研发团队的自助看板。

看板还有一个额外好处:研发可以直观看到自己申请的资源到底用了多少。很多人发现自己常年申请16GB但实际只用4GB之后,会主动降低配额,资源效率就这么一点点抠出来的。

7.4 版本兼容矩阵

驱动、固件、CANN/NNRT Runtime、HAMi版本、基础镜像,这五个维度,任何一项变更都必须先测再推。我们内部维护了一张兼容矩阵表,每次升级前对照矩阵逐项确认。这张表看起来简单,但救了我们很多次。

如果要让我总结这段经历,最值钱的不是8张卡跑30个环境这个结果,而是想清楚了一个问题:资源虚拟化不是万能的,它解决的是碎片化负载集中调度的问题,而不是物理资源从无到有的问题。开发环境这种低占用的碎片负载,用HAMi这类组件切分后非常划算;但真正需要整卡的重负载,依然要老老实实留出独立资源池。先把负载分类,再决定用什么工具,这个顺序比选型本身更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询