Serverless GPU混部翻车记:用MIG物理隔离和分时调度硬扛三个模型,延迟从抖动300ms压到10ms以内

那天晚上,告警群炸了:推理延迟飙到2秒,客户在直播间等出图

事情得从去年夏天说起,我们团队负责一个内容平台的 AI 推理服务,日活大概 30 万,不算大但业务场景挺花哨——同时跑着三个模型:一个 BERT 做实时评论情感分类,一个

事故复盘:时间片共享的假象与 GPU 饥饿

接到报警时我正窝在沙发上看《葬送的芙莉莲》缓解连续一周加班的疲惫。打开 Grafana 一看,推理延时 p50 直接飙到 1.8 秒,p99 接近 4 秒,错误率 12%。直播间评论区里用户已经开骂:“生成个图要十秒?这 AI 是骑驴画的吧?”

我一边远程连上集群,一边在群里吼:“谁动配置了?” 运维小张发来一条语音,声音都在抖:“没有动任何东西,就是流量上来之后突然崩的……”

三台 T4 独立部署时,即使峰值 QPS 翻倍,延时也不过从 80ms 涨到 250ms 左右,远没到这种灾难级。现在换成 A100 80G,理论上算力是 T4 的好几倍,怎么就垮了?先看 GPU 利用率:nvidia-smi 显示 GPU 计算利用率在 95%~100% 之间反复横跳,显存却只用了 42GB,完全有余量。这就怪了——看起来 GPU 跑得很满,但延时却极差。

我立刻抓了一个火焰图。PyTorch Profiler 显示,BERT 模型的某次推理中大量时间消耗在 cudaStreamSynchronize 上,而且这些等待并没有固定规律。更诡异的是,Stable Diffusion 模型在去噪步骤中频繁出现长达数百毫秒的 “气泡”,既不是计算也不是 I/O,纯粹是在等 GPU 空闲。那个轻量级推荐模型更惨,每次推理都要排队等 200ms 以上,完全失去了低延迟的特性。

直觉告诉我:这是典型的 GPU 时间片争抢。但 Kubernetes 的资源限制不是能隔离吗?我赶紧检查容器配置:

resources:
  limits:
    nvidia.com/gpu: 1
  requests:
    nvidia.com/gpu: 1
    memory: "16Gi"
    cpu: "8"

问题就出在这里——我们只声明了整卡 GPU 资源,Kubernetes 默认会把三个 Pod 调度到同一张 A100 上,以为每个容器分到 1 GPU,其实是同一张卡上的时分复用。NVIDIA 驱动并没有做严格的算力隔离,当多个进程同时往 GPU 上发射 kernel 时,SM(流多处理器)被争抢,一个进程的 kernel 可能被切碎,导致延时剧烈抖动。尤其是 Stable Diffusion 的 UNet 迭代步骤很多,一旦被打断,迭代的端到端耗时就会指数级放大。

为了验证,我用 nvidia-smi pmon 监控进程的 SM 占用,发现三个进程的 SM 占用在 0% 到 60% 之间剧烈波动,互相踩踏。这就是所谓的 “noisy neighbor” 问题——没有硬件级别的隔离,GPU 内核调度根本不关心 QOS,完全就是丛林法则。

我们临时把 Stable

p50 直接飙到 1.2 秒,p99 更是冲到了 3.8 秒,而且伴随着大量的超时和重试。我一看 GPU 利用率——好家伙,A100 跑出了 98% 的 load,显存却被啃掉 38GB,剩下的 2GB 像悬崖边上的石头,一碰就掉。直觉告诉我,这绝对不是正常现象。三个模型按理说计算量不大,一个情感分类的 BERT-base 才 110M 参数,batch_size 为 1 时显存占用不超过 2GB;一个 Stable Diffusion v1.5 用来生成直播间封面,fp16 下占用不到 4GB;还有一个 ResNet-50 做图片审核,更是轻量级选手,满打满算 1GB 显存。加起来总共才 7GB,怎么会把一台 A100-40G 撑爆?

但我错了。错得很离谱。问题的根源不是显存总量,而是 GPU 计算单元和内存带宽的争抢。我们的 Serverless 平台基于 Kubernetes,用 NVIDIA 的 GPU Operator 把三块模型分别打成三个容器,每个容器通过 nvidia.com/gpu 资源请求申请了一张完整的 A100。平台默认的策略是 GPU 共享,也就是没有做任何物理隔离,所有容器共享同一块 GPU 的 CUDA 核心和显存。平台认为这样能提高资源利用率,毕竟三个模型平时 QPS 都不高,峰值错开就行。然而现实是——当这三个模型同时跑起来时,它们会互相抢占 SM(流式多处理器)和显存带宽,导致内核启动延迟急剧升高,就像高速公路上三辆赛车同时抢占同一个车道。

三个模型的“性格”与隐性冲突

为了理解这场事故,有必要给这三个模型画个像。

BERT 实时情感分类: 这位是典型的延迟敏感型选手。业务方要求每条评论在 50ms 内返回情感标签,否则用户会看到“加载中”的菊花转半天。它的输入是用户刚发的弹幕或评论,平均长度 20 个 tokens,batch_size 固定为 1,因为评论是逐条来的,没法攒批。模型用 ONNX Runtime 部署,推理一次大约需要 8ms 的 GPU 计算时间,但加上前后处理,端到端延迟控制在 20ms 以内才算健康。它的计算特征是短小精悍的矩阵乘法,对 CUDA 核心利用率中等,但需要频繁访问显存读取模型权重。

Stable Diffusion 文生图: 这位是吞吐量怪兽兼延迟不敏感型。直播间主播需要每隔几分钟生成一张封面图,每次生成需要 20 步 DDIM 采样,单张图耗时约 2 秒。业务上能接受 5 秒内的延迟,但一天要生成几千张图,而且经常出现“突发三连”——某个大主播开播时,三张封面同时请求。它的计算特征是重度使用卷积和注意力,显存带宽占用极高,尤其每次去噪步骤都要把整个 UNet 权重在显存里搬来搬去。

ResNet-50 图片审核: 这位是扫黄打非的守门员,每天要过滤几十万张上传图片,批处理为主,每次推理 16 张图,耗时约 30ms。它对延迟要求不严格,但在高峰期吞吐量会飙到 500 QPS。模型很小,权重只有 100MB,推理时大部分时间花在数据预处理上,GPU 上就是几个卷积操作。

当这三个模型同时挤在一块 A100 上时,看似显存够用,实际上 GPU 内部的“交通调度”已经崩溃。BERT 需要低延迟,但它的 kernel 启动经常被 Stable Diffusion 的大块显存拷贝阻塞;Stable Diffusion 需要连续的计算流,但 BERT 的高频 kernel launch 把它的执行流切成碎片;ResNet 的批处理则因为 SM 被抢占,从 30ms 退化到 200ms。这就好比一个十字路口,一个赶时间的快递员(BERT)、一辆满载的货车(SD)、还有一队自行车(ResNet)同时抢道,信号灯却只有一个“先到先得”。

事故复盘:时间片共享的假象与 GPU 饥饿

接到报警时我正窝在沙发上看《葬送的芙莉莲》缓解连续一周加班的疲惫。打开 Grafana 一看,推理延时 p50 直接飙到 1.7 秒,p99 甚至触达了 5 秒超时线,错误率 12%。我立刻远程到服务器上,通过 nvidia-smi 看 GPU 状态:温度 82°C,风扇转速 85%,SM 利用率 98%,显存控制器利用率 95%,但 PCIe 吞吐量并不算高。这说明 GPU 内部计算单元忙疯了,而不是在等数据。

第一反应是某个模型跑飞了。我先查了 BERT 服务的日志,发现从 20:32 开始,ONNX 推理的 Run() 调用耗时从稳定的 8ms 一路涨到 400ms,中间没有任何异常输入的记录。再看 Stable Diffusion,它的推理管道耗时记录也异常:去噪步骤的每次 UNet 调用从正常的 90ms 涨到了 700ms。这些波动在时间轴上高度同步,且呈现出明显的周期性脉冲——每隔 20~30 秒出现一次尖峰,正好对应 Stable Diffusion 的一个生成任务。

我打开 nvidia-smi dmon 实时监控 SM 占用,发现一个典型场景:当 SD 开始去噪时,SM 利用率瞬间飙到 100%,BERT 的 kernel 启动请求会排队等待空闲的 SM。SD 的计算 kernel 本身就很“胖”,一次调用要占满几十个 SM 跑上几十毫秒,BERT 的轻量 kernel 只能在 SD kernel 切换的间隙插入执行。CUDA 的上下文切换并非抢占式,而是协作式——一个 kernel 一旦启动,就会一直占用分配到的 SM 直到结束,其他 kernel 只能等着。这就导致 BERT 的 kernel 被塞进非常小的空闲窗口,执行时间不稳定,端到端延迟也就忽上忽下。

更糟糕的是显存带宽的争用。SD 的去噪过程需要反复读写巨大的中间特征图(fp16 下约 4GB),把显存带宽全部吃满。BERT 的权重虽然常驻显存,但每次推理仍需要从显存读取嵌入层和注意力矩阵,这些读取请求被 SD 的大带宽流量冲得七零八落,延迟进一步恶化。我后来用 nsys 做了 profiling,发现在 SD 去噪期间,BERT 的 kernel 有 70% 的时间花在显存读取等待上,实际计算时间占比不足 30%。

这就是时间片共享的假象:NVIDIA GPU 的默认共享机制不是分时复用的,而是“全有或全无”。容器看到的是一块完整的 GPU,但实际上所有容器都在争抢同一套物理执行单元和内存子系统。对于延迟敏感型负载,这种争抢是灾难性的。

我尝试过用 CUDA MPS(Multi-Process Service)来缓解。MPS 可以在用户态把多个 CUDA 上下文的 kernel 合并提交到 GPU,减少上下文切换开销。但试验后发现,MPS 只对同时提交的小 kernel 有用,一旦遇到 Stable Diffusion 这种长时间占用 SM 的大 kernel,其他进程的 kernel 依然会被阻塞。而且 MPS 本身有额外开销,还会在客户端连接断开时触发服务器资源清理,导致短暂的抖动。在我们的场景下,MPS 把 p50 延迟从 1.7 秒“改善”到了 1.5 秒,根本于事无补。

临时止血方案是粗暴的:手动把三个模型绑到不同的 GPU 上。我们刚好有两台 A100 节点,一台跑两个,一台跑一个。延迟瞬间降到正常范围,但 GPU 资源利用率惨不忍睹——一台节点上的 BERT 和 ResNet 总共只用了 15% 的 SM,另一台节点的 SD 用了 30%,剩下的 GPU 都在空转吃电。老板看到电费账单后,脸色比芙莉莲的头发还青。

探索物理隔离:MIG 的正确打开方式

既然时间片共享靠不住,那物理隔离就是不得不走的路。NVIDIA 从安培架构开始引入了 MIG(Multi-Instance GPU),可以把一块 A100 切成最多 7 个独立的 GPU 实例,每个实例拥有专属的 SM、显存和缓存,彼此之间硬件隔离,绝不干扰。这简直就是为我的场景量身定做的。

不过,MIG 并不是银弹。它要求对整个推理部署架构做比较大的调整,而且有一些限制:切分后的实例固定了 SM 和显存的比例,不能动态调整;每个实例只能分配给自己一个容器;实例间不能共享显存,意味着如果要部署三个模型,就必须提前规划好每个模型需要的显存和 SM 数量。

我先在测试环境拿一块 A100-40G 做切分实验。A100 有 108 个 SM,40GB 显存。按照 nvidia-smi mig 的规则,我可以创建不同规格的 GPU 实例(GI)和对应的计算实例(CI)。

三种典型配置方案:

  • 方案一: 3g.20gb × 1(21 SM,20GB)+ 2g.10gb × 1(14 SM,10GB)+ 1g.5gb × 1(7 SM,5GB),合计 42 SM,35GB,剩余 SM 和显存会被浪费。
  • 方案二: 2g.10gb × 2(每个 14 SM,10GB)+ 1g.10gb × 1(7 SM,10GB),合计 35 SM,30GB,利用率更低。
  • 方案三: 使用 3g.20gb(21 SM)给 SD,2g.10gb(14 SM)给 BERT,1g.5gb(7 SM)给 ResNet,分别分配 20GB、10GB、5GB 物理显存。这是我最终采用的组合,SM 利用率约 39%,显存利用率 87.5%。虽然不算高,但比之前的完全共享模式稳定得多。

配置 MIG 的命令行操作大致如下:

# 启用 MIG 模式
sudo nvidia-smi -mig 1

# 查看可用配置
nvidia-smi mig -lgip

# 创建 GPU 实例
sudo nvidia-smi mig -cgi 9,9,9 -C

# 创建对应的计算实例
sudo nvidia-smi mig -cci 0 -gi 0
sudo nvidia-smi mig -cci 0 -gi 1
sudo nvidia-smi mig -cci 0 -gi 2

其中 -cgi 9 表示创建 3g.20gb 的 GPU 实例,-cci 0 为指定实例创建单个计算实例。完成后,通过 nvidia-smi 可以看到三个独立的 GPU 设备(例如 /dev/nvidia-caps 对应),每个都有自己的 SM 和显存占用,互不干扰。

在 Kubernetes 里,需要用 NVIDIA 的 GPU Operator 配合 mig-manager 插件来调度 MIG 资源。关键是在 Pod 的资源请求里指定具体的 MIG 实例标签,而不是笼统的 nvidia.com/gpu: 1。例如,BERT 的 Deployment:

apiVersion: v1
kind: Pod
metadata:
  name: bert-inference-pod
spec:
  containers:
  - name: bert-server
    image: bert-onnx-server:latest
    resources:
      limits:
        nvidia.com/mig-2g.10gb: 1

这样调度器就会把这个 Pod 绑定到我们切割出来的 2g.10gb 实例上,BERT 独享 14 个 SM 和 10GB 显存,其他模型完全碰不到。同理,SD 请求 3g.20gb,ResNet 请求 1g.5gb。

上线 MIG 后,三个模型终于相安无事。我用 locust 压测同时发送三个模型的请求,SD 的生成延迟稳定在 2.1 秒左右,BERT 端到端延迟 p50 降到 12ms,p99 20ms,ResNet 批处理 30ms 岿然不动。GPU 利用率依然不高,但至少延迟抖动消失了,客户也不再骂娘。

然而新的问题浮现了:MIG 的切分是静态的,可业务流量是波动的。晚上 8 点直播高峰,SD 的请求量是平日的 5 倍,需要更多 SM;但半夜 2 点,图片审核的批处理任务爆发,ResNet 又需要扩容。静态切分导致的资源浪费,在老板眼里就是钱在烧。我需要一种更灵活的方式,既能物理隔离,又能动态调整计算资源分配。

分时调度:让 GPU 实例“活”起来

静态 MIG 不够用,我们需要在物理隔离的基础上实现分时复用——也就是说,允许动态地重新配置 MIG 实例或者在不同的时间段切换实例配置。但 NVIDIA 的 MIG 在运行时不支持热重配置,任何 GI/CI 的修改都需要先销毁所有容器,这在生产环境意味着停服,不可行。

我的思路转向了“分时调度”:不改变 MIG 的物理切分,而是在调度层面按时间段将不同的模型绑定到不同的 MIG 实例上,或者在不同时间使用不同数量的 Pod 实例来调节对 MIG 资源的占用。但问题在于,如果 MIG 实例已经固定,那么某个时间段如果某个模型不需要那么多 SM,这些 SM 就闲置了,无法被其他模型用上。

另一种方案是使用“临时性 MIG 实例切换”:在低峰期关闭某个模型的所有 Pod,销毁该模型占用的 MIG 实例,重新分配更大的实例给其他模型,然后在高峰期之前切回来。这需要编排一套复杂的流程,涉及 Pod 优雅下线、MIG 重新配置、应用重新部署,风险太高。

最后我找到了一个折衷方案:MIG + 时间段分流的弹性副本数。核心思想是:依然保持三个固定的 MIG 实例,因为它们的显存分配刚好满足三个模型的最低要求。但通过 Kubernetes 的 HPA(水平自动扩缩容)在不同时间段调整每个模型的 Pod 副本数,从而把流量分流到多个同型号的 MIG 实例上。如果某个模型需要更多计算能力,就增加它的 Pod 数量,用负载均衡分摊流量,但每个 Pod 仍然绑定到同一种 MIG 切片——比如一个 2g.10gb 的实例只能跑 BERT 的一个 Pod。这要求我们有多块 GPU 或者多个节点,每个节点上配置相同的 MIG 切片模板。

于是,我们在三个 A100 节点上都配置了相同的 MIG 分区:每个节点切出 1 个 3g.20gb、1 个 2g.10gb、1 个 1g.5gb。这样整个集群有 3 个 BERT slot(2g.10gb)、3 个 SD slot(3g.20gb)、3 个 ResNet slot(1g.5gb),总共 9 个隔离的计算槽位。然后我们根据时段通过 cron HPA(比如 KEDA 的 cron scaler)来增减这些模型的 Pod 数量,从而使用更多的槽位。

比如,晚上 8 点直播高峰,SD 的 HPA 会把副本数从 1 扩到 3,占用所有三个 3g.20gb 槽位,享受 63 个 SM 的总算力,而 BERT 和 ResNet 继续维持 1 个副本。凌晨 2 点审核高峰,ResNet 扩大到 3 个副本,占用三个 1g.5gb 槽位。白天平稳时段,三个模型各跑一个副本,相安无事。

这个方案的精妙之处在于:每个 Pod 所绑定的 MIG 实例是固定类型的,因此物理隔离得到了保证;同时通过副本数调整,模型的整体计算吞吐量可以弹性伸缩,而且伸缩过程不会破坏隔离性,因为新增的 Pod 会使用另一个节点上的同类型 MIG 实例。我们只是利用多节点实现了资源池化。

我写了一个简单的调度流量切换脚本,利用 Prometheus 监控每个模型的推理队列深度,超过阈值就触发扩缩容。不过后来发现更简单的做法是用定时的 CronJob 来调整 HPA 的 minReplicas,因为业务流量的潮汐效应非常有规律。代码如下:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: scale-sd-up
spec:
  schedule: "0 19 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: scaler
            image: bitnami/kubectl:latest
            command:
            - /bin/sh
            - -c
            - kubectl scale deployment stable-diffusion --replicas=3
          restartPolicy: OnFailure
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: scale-sd-down
spec:
  schedule: "0 23 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: scaler
            image: bitnami/kubectl:latest
            command:
            - /bin/sh
            - -c
            - kubectl scale deployment stable-diffusion --replicas=1
          restartPolicy: OnFailure

当然,真正的生产集群需要更平滑的扩缩容,我们后来集成了 KEDA 的 ScaledObject,基于 Prometheus 指标来做精确控制,但定时伸缩作为保底手段非常可靠。

代码实战:服务端如何感知 MIG 实例并优化推理性能

有了硬件隔离,服务代码本身也需要做些优化,才能把延迟从 20ms 进一步压到 10ms 以内。BERT 端到端延迟从 20ms 降到 10ms 看似只有 10ms 的差距,实际上需要扣到每一个微秒。

首先,我们给 BERT 推理服务加上 CUDA Stream。在没有指定 stream 的情况下,CUDA 操作会跑在默认 stream 上,所有 kernel 串行执行。但有了 MIG 专用的 SM 组后,我们可以利用多个 stream 实现内核间的并发执行,尤其是把前后处理的 CPU 工作和 GPU 推理重叠起来。我用 PyTorch 配合 ONNX Runtime 的执行 provider,指定 CUDAExecutionProvider 并且设置多个 stream 选项:

import onnxruntime as ort
import numpy as np

session_options = ort.SessionOptions()
session_options.intra_op_num_threads = 1
session_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
providers = [
    ('CUDAExecutionProvider', {
        'device_id': 0,  # 对应 MIG 实例
        'arena_extend_strategy': 'kNextPowerOfTwo',
        'gpu_mem_limit': 8 * 1024 * 1024 * 1024,  # 8GB 给 BERT 足够
        'cudnn_conv_algo_search': 'EXHAUSTIVE',
        'do_copy_in_default_stream': True,
    })
]
session = ort.InferenceSession("bert_model.onnx", sess_options=session_options, providers=providers)

# 使用 CUDA stream 异步推理
import torch
stream = torch.cuda.Stream()
with torch.cuda.stream(stream):
    outputs = session.run(None, {"input_ids": input_ids, "attention_mask": attention_mask})
stream.synchronize()

关键是在 MIG 环境下,device_id 的索引不再是物理 GPU 序号,而是 MIG 实例的序号。通过 nvidia-smi -L 可以列出所有 MIG 设备,例如:

GPU 0: A100-SXM4-40GB (UUID: GPU-xxxx)
  MIG 2g.10gb Device 0: (UUID: MIG-yyyy)
  MIG 1g.5gb  Device 1: (UUID: MIG-zzzz)

应用程序要用环境变量 CUDA_VISIBLE_DEVICES 锁定到具体的 MIG UUID,这样 ONNX Runtime 才能正确选择。我们在 Kubernetes Pod 里已经做了资源限制,所以 BERT 容器只能看到分配给它的那个 MIG 设备,device_id 恒为 0。

另一个优化是消除 BERT 推理中的小 batch 启动开销。由于每条评论单独推理,我们使用了动态 padding 和 kernel 融合。ONNX 模型在导出时使用了 onnx-simplifiertrtexec 进行了 TensorRT 优化,但 MIG 实例的 SM 数量少,TensorRT 默认的 auto-tuner 可能会选出不适合小 SM 数量的 kernel。我通过 nsys 分析了 TensorRT 引擎的 enqueueV2 调用,发现有些 kernel 的 grid size 太大,导致在小 SM 数量下调度效率低下。于是手动调整了 TensorRT 构建配置,限制最大 workspacesize,并强制使用较小的 tile size:

config.set_flag(trt.BuilderFlag.FP16)
config.set_tactic_sources(1 << int(trt.TacticSource.CUBLAS) | 1 << int(trt.TacticSource.CUBLAS_LT))
config.max_workspace_size = 1 << 28  # 256 MB

效果显著,kernel 启动开销从 50μs 降到了 15μs。

对于 Stable Diffusion 服务,MIG 带来的 21 个 SM 比完整 A100 的 108 个少了太多,直接跑会慢很多。但我们做了两件事:一是用 xformers 的 memory efficient attention,它在 SM 少时优势更明显;二是调整了 UNet 的注意力头数分块策略,让计算更均匀分布在 21 个 SM 上。最终在 3g.20gb 实例上 SD 的每张图生成时间稳定在 2.8 秒,虽然比完整 A100 的 1.8 秒慢,但考虑到隔离性和成本,完全可接受。

意外收获:显存隔离解决了一个隐藏的内存泄漏 Bug

在实施 MIG 隔离后,我们还意外发现并解决了一个隐藏了三个月的内存泄漏问题。之前因为所有容器共享显存,很难定位是哪个模型在缓慢吃掉显存。我们经常观察到 A100 的总显存占用在几个月里从 25GB 慢慢涨到 35GB,然后触发 OOM,但重启容器后立刻恢复。MIG 切分后,每个模型被限制在自己的显存沙箱里,如果哪个模型显存泄漏,它会很快在自己的配额内爆炸,而不会影响其他模型。

果然,启用 MIG 两天后,ResNet 的 Pod 突然因为 OOMKilled 重启。查看日志,发现是图片审核服务在加载某些损坏的 JPEG 时,PIL 库的解码器会分配一大块 CPU 内存,然后拷贝到 GPU,但没有正确释放 GPU 缓存。由于 ResNet 被限制在 5GB 显存,泄漏很快触顶,问题暴露无遗。如果没有 MIG,这个泄漏会被其他模型的显存余量掩盖,直到整个 40GB 用光,定位起来大海捞针。

修复后,整个集群的显存占用曲线变得非常平稳,运维团队甚至不需要设置显存使用量的报警了。

延迟压到 10ms 以内的最后一步:CPU 绑核与中断亲和

尽管 GPU 端已经优化到极致,BERT 端到端延迟仍然在 15ms 左右抖动,离目标 10ms 还差一点。我用 perfbcc 工具分析后发现,瓶颈转移到了 CPU 侧——容器在接收 HTTP 请求、预处理 tokenization、以及从 GPU 拷贝数据回来时,经常被调度到不同的 CPU 核上,导致缓存失效和上下文切换开销。对于 10ms 级别的延迟来说,这是不可忽略的。

我们给 BERT 的 Pod 添加了 CPU 管理策略:cpu-manager-policy: static 并请求整数个 CPU(如 cpu: 4),让 kubelet 把四个物理核独占给这个 Pod。同时使用 taskset 把 ONNX Runtime 的内部线程绑到这 4 个核上,避免线程漂移。

另一个细节是网络中断。Kubernetes 节点的网卡中断默认分布在所有 CPU 上,可能会抢占我们的计算核。通过设置 irqbalance 并禁止特定 CPU 核接收中断,或者用 DPDK 绕过内核网络栈,可以进一步减少抖动。我们的场景没有采用 DPDK,但使用了 node-tuning-operator 把 BERT Pod 所在的 NUMA node 的网络中断绑到其他空闲核上。

apiVersion: tuned.openshift.io/v1
kind: Tuned
metadata:
  name: bert-latency
spec:
  profile:
  - data: |
      [main]
      summary=Optimize for low latency BERT inference
      [sysctl]
      net.core.busy_poll=50
      net.core.busy_read=50
      [bootloader]
      isolcpus=4-7,12-15
      nohz_full=4-7,12-15
      rcu_nocbs=4-7,12-15
    name: bert-latency

最终效果:在压测并发 2000 的情况下,BERT 端到端延迟 p50 稳稳压在 9.5ms,p99 13ms,满足了业务的苛刻需求。

效果展示与压测对比

为了验证整个架构的鲁棒性,我们进行了三组压测对比:纯共享模式(未隔离)、单 MIG 静态模式(每个模型一个固定 MIG 实例,无弹性副本)、MIG + 分时弹性模式(最终方案)。压测环境使用两台 A100-40G 节点,工具是 Locust,模拟业务流量规律。

场景一:晚 8 点直播高峰,SD 请求量 10 QPS,BERT 500 QPS,ResNet 300 QPS。

方案 SD p50 延迟 BERT p50/p99 ResNet p50 GPU 总利用率 错误率
纯共享 8.2s (超时) 340ms/2.1s 210ms 99% 12%
单 MIG 静态 2.9s 15ms/25ms 35ms 62% 0%
MIG + 分时弹性 2.8s (3 副本) 9.5ms/13ms 30ms 78% 0%

场景二:凌晨 2 点审核高峰,ResNet 600 QPS,SD 2 QPS,BERT 100 QPS。

方案 SD p50 BERT p50 ResNet p50 GPU 总利用率
纯共享 4.5s 120ms 460ms 97%
单 MIG 静态 2.9s 15ms 35ms 40%
MIG + 分时弹性 2.9s 14ms 31ms (3 副本) 72%

从数据可以看出,MIG + 分时弹性在保障延迟的同时,GPU 利用率比静态 MIG 提高了 16%~32%,虽然还没达到共享模式的高利用率,但业务稳定性提升巨大。考虑到业务损失的成本远大于几块 GPU 费用,这笔账很划算。

踩过的坑与反思

整个过程中,踩过的坑远不止上面这些,挑几个印象深刻的分享。

MIG 与 CUDA11.x 的兼容性噩梦: 早期我们一个节点升级了 CUDA 11.6 驱动,结果 MIG 实例创建成功,但容器内的 PyTorch 无法识别设备,报错“no CUDA-capable device”。原因是容器镜像里的 CUDA runtime 版本与主机驱动不匹配,且 MIG 需要额外的库支持。统一使用 NVIDIA 的 cuda:11.7.1-runtime-ubuntu20.04 基础镜像,并在镜像中复制匹配的 libcuda.so 后解决。

Kubernetes 调度器不认识 MIG 资源: 最初用原生调度器,发现 MIG Pod 一直 Pending。需要安装 nvidia-k8s-device-plugin,并配置 config.yml 打开 MIG 策略:

version: v1
sharing:
  timeSlicing: 
    renameByDefault: false
    failRequestsGreaterThanOne: false
    resources:
    - name: nvidia.com/gpu
      replicas: 4
mig:
  strategy: mixed

显存预留不足导致 SD 报 OOM: 虽然切了 20GB 给 SD,但加载 fp16 的 SD 1.5 权重占 3.8GB,文本编码器占 1.2GB,VGG 编码器 1.5GB,中间 latent 加噪声预测的临时缓冲区需要大约 10GB,加起来刚好 16.5GB,看似安全。但 PyTorch 的缓存分配器会预留一部分显存,加上 CUDA 上下文开销,实际空闲显存不足,偶尔 OOM。通过设置 PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128 并限制 gpu_mem_limit 到 18GB 解决。

网络代理的额外一跳: 我们 Serverless 平台用 Istio sidecar 做流量管理,sidecar 的代理转发会给每个 HTTP 请求增加约 2ms 的延迟。对于 BERT 的目标来说这太多了。最后把 BERT 服务放到没有 sidecar 注入的 namespace,改用直连的 ClusterIP Service。

架构演进总结与未来展望

回顾这段历程,从最初盲目相信“GPU共享能搞定一切”,到被现实教育,再到利用 MIG 物理隔离和分时弹性调度实现低延迟与成本平衡,我对 GPU 混部的理解深了一层。关键认知有几点:

  • 延迟敏感与批处理不能共享 GPU。 这是第一铁律,除非有硬件级别的抢占或时间片隔离(目前 NVIDIA GPU 不支持)。
  • MIG 不是万能药,但它是目前混部的最佳平衡点。 它牺牲了一部分利用率,换来了可预测的延迟和故障隔离,在商业上划算。
  • 弹性副本加固定 MIG 切片,是一种简单有效的资源池化模式。 它绕开了动态重切 MIG 的难题,用水平扩展代替垂直扩展。
  • 端到端优化必须从硬件一直看到应用层。 哪怕 kernel 启动多 10μs,CPU 绑定差一个核,都可能让 10ms 的目标功亏一篑。

目前这套架构稳定运行了五个月,没有再出现延迟告警。但我仍然在关注 NVIDIA 的下一代技术,比如 Hopper 架构的 MIG 功能更强,支持动态调整 SM 分配?或者利用 MPS-aware MIG 在一个实例内再分时复用?甚至考虑将延迟极度敏感的 BERT 迁移到 NVIDIA Triton Inference Server 上的模型并发执行(dynamic batching)与 MIG 结合,进一步提升 GPU 利用率。未来可期。

如果这个故事能给你一点启发,比如下次当你的推理服务在 GPU 共享集群里无故抖动时,先别急着加机器,试试 MIG 吧。至少,它能让你睡个安稳觉,不用半夜被芙莉莲的台词吵醒——虽然我还是想把《葬送的芙莉莲》看完。

发表评论