Kotonia
ログイン今すぐ始める

Kotonia Articles

LLM 掉到了 2%。我把 GPU 共存的天花板测了三遍

LLM 解码是带宽受限,扩散模型是算力受限,既然吃的资源不同就应该能共存于同一块 GPU——我用 CUDA MPS 和 SM 分割把这个假设测了三遍。结论是「不是空间,是时间」。

作者 7分钟阅读
#gpu#cuda#vllm#blackwell#llm
其他语言日语英语

前言

我这台机器上装了两块 GPU,语音对话、口型同步数字人、LLM、图像生成、视频生成全都跑在上面。第三块卡快到了,所以我在规划哪个负载该放在哪块卡上。

过程中冒出来一个看着挺有道理的假设。

LLM 的解码是显存带宽受限的,扩散模型是算力受限的。既然吃的资源不一样,放在同一块卡上不就应该互不干扰吗?

NVIDIA 有个叫 MPS(Multi-Process Service)的功能,看上去正是为此而生。于是我试了。

先说结论:假设是错的。而且我判定它「错」的那个过程,本身也错了两次。 测了三遍,才拿到一个真正能解释现象的模型。下面是这三遍的完整记录。


第一幕:空间的假设

先测两块卡的 Roofline

没有基准就没法讨论,所以先测。RTX PRO 6000 Blackwell Max-Q(96GB)和 RTX PRO 4000 Blackwell(24GB)。

6000 Max-Q4000比值
SM 数量188702.69x
显存带宽1469 GB/s553 GB/s2.66x
bf16 GEMM(持续)228 TFLOPS103 TFLOPS2.22x
拐点(ridge point)155.5 FLOP/byte185.9 FLOP/byte
4 秒内的降频-2.0%-0.9%

有意思的是,两块卡的显存频率(14001MHz)和 SM 频率(3090MHz)完全一致,差别只在位宽、SM 数量和功耗墙。带宽比(2.66x)大于算力比(2.22x),6000 的拐点也更低,说明它是相对「喂得更饱」的那块卡。

我本来怀疑 Max-Q 的 300W 限制会砍算力,但 4 秒的 GEMM 只掉了 2.0%。

假设的依据

拐点就是「以这个运算强度为界,带宽受限和算力受限互换」的位置。6000 是 155.5 FLOP/byte。

LLM 解码远在它下方。 吐一个 token,要把每一层的每个权重矩阵从头读一遍,各自乘上「当前这一个 token 的细向量」。权重读一次、用一次、扔掉,完全没有复用,所以运算强度只有 2B/bb 是每参数字节数,B 是 batch)。NVFP4(0.5 byte/param)在 batch 1 下就是 4 FLOP/byte,是拐点的 1/39。

把实测带宽套到 27B 的 NVFP4 模型(约 13.5GB)上:

13.5 GB / 1469 GB/s = 9.2 ms/token  →  理论上限约 109 tok/s

LLM 的速度不由算力决定,而由「能多快把权重从显存里抽出来」决定。 Tensor Core 基本在闲着。

扩散模型在另一侧。 2048² 的图像生成会把读进来的数据复用成千上万次,运算强度在几百到几千。带宽空得很,只有 Tensor Core 满负荷。

资源使用的位置分得很干净。所以应该能重叠——这就是当初的假设。

但首先:GPU 根本不会同时跑不同进程的 kernel

GPU 是「一进程 = 一个 CUDA context」,而且不同 context 的 kernel 不会在同一批 SM 上同时运行。GPU 是按 context 整体切换、轮流处理的。也就是分时。

没有 MPS —— 分时。任何时刻只有一边在跑
时间 →
LLM       ████░░░░████░░░░████░░░░
图像生成   ░░░░████░░░░████░░░░████
SM 占用    低  高  低  高  低  高    ← LLM 的回合里 Tensor Core 在闲置
带宽占用   高  低  高  低  高  低    ← 图像的回合里带宽在闲置

MPS 改变了这一点。守护进程作为代理,把所有进程的 GPU 工作汇集到一个共享 context 里,于是不同进程的 kernel 就能真正同时跑了——理论上。

造一个测量仪

直接让真家伙对打,什么都说明不了。所以我做了两个代理,复现的不是真实 kernel,而是真实的「Roofline 上的位置」

带宽侧(LLM 解码的代理)——巨大矩阵乘细向量。每次读 537MB,却只算 537 MFLOP。运算强度 1.0 FLOP/byte。

w = torch.randn(16384, 16384, dtype=torch.bfloat16, device="cuda")  # 537 MB
x = torch.randn(16384, 1, dtype=torch.bfloat16, device="cuda")      # batch 1
while ...:
    torch.mm(w, x, out=y)

算力侧(扩散模型的代理)——方阵乘方阵。每次碰 1.6GB,算 8.8 TFLOP。运算强度 5461 FLOP/byte,在拐点上方 35 倍。

a = torch.randn(16384, 16384, dtype=torch.bfloat16, device="cuda")
while ...:
    torch.mm(a, b, out=c)

如果只是朴素地同时启动,会混进「一边先跑完、另一边独占 GPU」的时间。所以给两个进程一个共同的 UNIX 时刻,各自预热后在屏障处会合,在完全相同的 5 秒窗口内计量。

而这个实验真正的仪器,是「归一化之后的和」。 把各条件的吞吐除以单独运行时的值,然后把两边相加。

和 ≈ 1.0  →  零和。一边的所得就是另一边的所失 = 没有重叠
和 >  1.0  →  真的重叠了。原本闲置的资源被填上了

把「到底是在重叠,还是在排队」压缩成一个数字。

结果:分时是零和,而 MPS 把带宽侧饿死了

单独运行的基准是带宽侧 1435 GB/s、算力侧 232 TFLOPS。

带宽侧算力侧
无 MPS44%51%96%
MPS / 不设上限2%98%100%

无 MPS 时和正好落在零和上。跟理论完全吻合,也确认了仪器没坏。

然后开了 MPS,带宽侧比分时还差 21 倍

为什么:「不用算力」和「不需要 SM」是两回事

这就是假设的漏洞。读显存同样需要线程驻留在 SM 上去发出 load 指令。 「不用 Tensor Core」和「不需要 SM」完全是两码事。

算力侧的 GEMM 是单发 37.9ms 的怪物 kernel,会用 thread block 把 188 个 SM 全部塞满。而且在 block 粒度上没有抢占,一旦上去就跑到底。带宽侧的 GEMV 只能排在队尾,根本发射不出去

分时的时候,至少还轮得到自己。开了 MPS 之后,换来的是「可以并排跑」,代价是「座位永远不空」。

加上 SM 上限就能回来,但那是一笔常年缴的税

MPS 有个 CUDA_MPS_ACTIVE_THREAD_PERCENTAGE,可以按客户端限制 SM 的份额。掐住算力侧,带宽侧就回来了。

算力侧的 cap带宽侧算力侧
不限2%98%100%
50%61%43%104%
25%85%22%107%
10%93%7%100%

再让它单独运行(没有对手)去测,就发现 cap 不是竞争时的仲裁,而是固定税。cap 25% 时,就算旁边一个人都没有,也只有 46%。

cap带宽侧(单独)算力侧(单独)
100%100%100%
50%93%75%
25%61%46%

这里有个关键的不对称:带宽侧砍掉一半 SM 仍有 93%。 一半就足够把带宽喂饱。

两边都加 cap,SM 依然不会被分割

根据单独 cap 的实测,可以推出「如果真的被分割」应该是多少。带宽侧 50%(单独 93%)加算力侧 50%(单独 75%),和应该是 168%。

设置若真分割实测
带宽 50 / 算力 50168%107%
带宽 50 / 算力 25139%102%
带宽 75 / 算力 25~143%109%
带宽 25 / 算力 75~149%99%

差得太远。不管怎么调,和都贴在 99〜109% 之间,天花板始终在 107% 左右不动。cap 是「你最多能用多少 SM」的上限,而不是「给你留着」。它能拿走,但保证不了。

我在这里差点就下了结论

把一块 GPU 分给两个都能吃满的负载,存在硬件层面的天花板。分时是零和,MPS 无论怎么配都在 107% 左右,MIG 在这代卡上不支持。软件层面越不过去。

结论很漂亮,数字也齐。而且是错的。


第二幕:怀疑自己的仪器

让我别扭的是「cap 是上限而不是预留」这一句。那如果真的做出彼此不相交的 SM 集合呢?

CUDA 有个 %smid 特殊寄存器,能读到自己跑在哪个 SM 上。让落在自己范围之外的 block 立刻 return,就能不依赖任何库做出真正的分割。

__device__ __forceinline__ unsigned smid() {
  unsigned r; asm volatile("mov.u32 %0, %%smid;" : "=r"(r)); return r;
}

__global__ void bw_pass(...) {
  unsigned s = smid();
  if (s < lo || s >= hi) return;    // 不是我的 SM,直接下车
  ...
}

吸取第一幕的教训重做了:放弃常驻 block(占着直到截止时刻),改成像真实 kernel 那样反复 launch;缓冲区加到 16GiB,确保溢出 128MiB 的 L2;用原子工作队列,保证被 mask 掉的 block 不会导致数据漏读。

结果:分割没用。但和有 139%

带宽侧 单独(全部 SM)        1650 GB/s
带宽侧 单独(只用下半 SM)    1651 GB/s   ← 一半 SM 就把带宽完全喂饱(100%)
算力侧 单独(只用上半 SM)    53.9 TFLOPS(56%)
  → 若分割完全生效,和应该是 157%

同时 / SM 共享                和 139%
同时 / SM 分割                和 138%   ← 没有变化

显式分割 SM,和并没有提高。 带宽侧用一半 SM 就已经饱和,所以从一开始就没有可分割的余地。对这一对负载来说,硬件的贪心调度器本来就已经接近最优。

但真正扎眼的不是这个。和有 139%。 跟 MPS 的天花板 107%、分时的 96% 完全不是一个世界。

用同一套 kernel,只改进程结构

第一幕用的是 cuBLAS,第二幕用的是自写 kernel。kernel 不同就没法比较。于是固定 kernel、只改结构重测了一遍。

结构带宽侧算力侧
2 进程 / MPS OFF48%48%96%
2 进程 / MPS ON66%66%132%
1 进程 2 stream / SM 共享91%48%139%
1 进程 2 stream / SM 分割95%44%138%

MPS 把 96% 提到了 132%,它是正常起作用的。 第一幕那句「天花板只有 +7%」,作为一般命题是假的。

真凶是 kernel 的粒度

差别有两处。

单发的长度差了 7 倍。

  • cuBLAS GEMM:37.9 ms/发
  • 自写 kernel:5.2 ms/发

既然 block 粒度上没有抢占,就只能「等它空出来」。这 7 倍直接变成了插队的难易度。

而且我在占用率上留了空隙。 实测:

每个 SM 的最大线程数    : 1536
自写 kernel             : 每线程 20 个寄存器,shared memory 0 B
  → 理论上能塞进 6 block/SM(占用率 100%)
  → 但我实际只 launch 了 4 block/SM
  → SM 的线程槽空着 33%

那空出来的 33%,正是对方的 block 挤进去的地方。cuBLAS 恰好相反,它用大块 shared memory tile 和大量寄存器,被调优成把占用率吃干净,结构上不给别人留缝。

换句话说,我的代理作为扩散模型的替身太轻了。 运算强度这个 Roofline 上的位置是对的,但占用 SM 的方式完全不同。只对齐 Roofline,做不出合格的代理。


第三幕:拿真家伙来对答案

代理不可信,那就测真的。不过这次我先把预测写下来再测。

HiDream 的重层(attention 和 linear)归根结底调的是 cuBLAS / cuBLASLt,那正是「又长又吃满占用率」的那类 kernel,所以应该偏悲观。但扩散的一步不是一发巨型 GEMM,而是 layernorm、逐元素运算、softmax、小投影等几百个 kernel 排成的队列,其间占用率会掉下来。所以应该落在 107%(cuBLAS 级)和 132%(细粒度级)之间,偏下端。

我把 ThinkCap-27B(NVFP4, vLLM)和 HiDream-O1-Image 从生产环境隔离出来,清空 GPU 后测量。图像保持生产同样的 2048²,只把步数从 50 降到 10——步数只是同一批 kernel 的重复次数,所以能在保持粒度的前提下缩短实验时间

结果

基准:LLM 71.83 tok/s(TTFT 62.0ms)、图像 8.12 秒/张。

条件LLMTTFT图像
MPS OFF(现状)33.18 tok/s46.2%84.5 ms15.05 秒54.0%100.1%
MPS ON18.5325.8%126.1 ms9.1388.9%114.7%
MPS ON + 图像 SM 25%52.8073.5%67.2 ms36.8622.0%95.5%

114.7%——落在预测区间的下端。

顺带一提,基准的 71.83 tok/s 是第一幕里根据带宽推出的上限 109 tok/s 的 66%。考虑到 MTP 的开销和 KV cache 的读取,这个数字是合理的,说明 Roofline 模型本身依然成立。

三种配置,而且「最优」按指标看每次都不一样

  • 总和最大的是 MPS ON(114.7%),但 LLM 掉到 25.8%
  • 总和最小的是 MPS + cap(95.5%,比分时还低),但 LLM 最好,有 73.5%
  • 分时在所有指标上都居中

决定对话体验的是 LLM 的 token 速度和 TTFT。只看这一条轴:

现在(分时)           : 46.2% / TTFT 84.5ms
MPS + 图像 cap 25%     : 73.5% / TTFT 67.2ms   ← 几乎回到空载时的 62.0ms

图像生成期间的对话速度快了 1.6 倍,TTFT 几乎回到空载水平。代价是图像从 15.1 秒变成 36.9 秒(慢 2.4 倍)。图像是异步的、可以排队,所以这笔交易明显划算。

总和最小的那个配置,对产品来说反而是最优的。 吞吐总和在这个问题里是错误的货币。


结论:不是空间,是时间

测了三遍,最后剩下的是这个。

关于空间的讨论——吃哪种资源、放在哪个 SM——从头到尾一次都没起作用。

作为出发点的 Roofline 分析是讲得通的,而且是正确的——关于单独运行时的速度。而关于两者能否共存,它几乎什么都没预测出来。数据反复在说这件事。

  • 带宽侧用一半 SM 就把带宽跑满 100%(也就是说,一开始就没有可分的空间)
  • 显式做出不相交的 SM 集合,和只从 139% 变成 138%(也就是说,空间分离没有价值)
  • 而另一边,只是把 kernel 的长度从 37.9ms 改成 5.2ms,就从 107% 动到了 132%

一直在起作用的,是「能插进去多少」。而它是两件事的乘积。

问题由什么决定
周转座位什么时候空出来单发 kernel 的长度(block 粒度没有抢占,只能等它跑完)
空位空出来时有没有地方进邻居没用掉的占用率

两者都是能不能入场的问题,跟进去之后用哪个运算单元毫无关系。

有意思的是,唯一起作用的那个空间旋钮(SM cap),其实也是在时间轴上起作用的。 它并没有给谁专属的 SM,而是把贪心客户端的足迹缩小,从而提高座位空出来的频率。所以纯粹的空间分割没有价值,而作为上限的 cap 却有效。

落到实务上

在别人的代码里无计可施。 把 kernel 写短、在占用率上留余地,这些是自己写的 kernel 才能设计的东西。vLLM 和 cuBLAS 的内部动不了。让第三方技术栈共存这个问题,原理上只有物理隔离一条解。

然后就回到自己的产品。我们做的是实时语音角色扮演,对话回路是「语音检测 → 语音识别 → LLM → 语音合成 → 口型同步数字人」。其中数字人的帧生成带着硬性截止时间

用这个模型看,数字人在共存下崩掉的原因就一目了然。数字人在每个 400ms 的截止周期里,有一条 encode → 9 次去噪 → decode 的依赖链。链上的每一环都要单独参加一次入场抽签。 这不是一次吞吐下降,而是要连续中签 11 次。所以哪怕平均只慢 1.3 倍,最差的那个 block 也会错过截止。

答案就是物理隔离。把实时路径(语音和数字人)隔离到专用卡上,LLM 和图像/视频生成放到另一块。快到的第三块卡,就是用来把这条线画完的。

MPS + cap 不上线。LLM 73.5% 很诱人,但守护进程会变成单点故障,vLLM 在 MPS 下长时间运行也没有实绩,更重要的是卡一到问题本身就消失了。把它记录为「不得不在一块卡上共存时的手牌」。

测三遍学到的东西

  • 验证仪器只能证明它「没坏」。 第一幕里看到无 MPS 那一栏正好是 96%(零和),我就信了这个仪器。它确实没坏,只是不具代表性。这是两件不同的事
  • 做代理时,只对齐 Roofline 上的位置是不够的。 不把占用方式也对齐,就不能拿来做共存实验
  • 先写下预测再测量,命中时才能说「这个模型有预测力」。 第三幕押中了 114.7%,意味着下次再往上加负载时,动手之前就能在纸上判断。这才是三遍下来真正的收获

这个实验的边界

  • 测的只是性格两极的一对负载。三个以上,或者 CPU 侧同步很重的负载,是否同样成立没有验证
  • 视频生成(LTX-2)的 88 秒里有近一半是在从磁盘加载、GPU 空闲,所以按这个模型推断它反而是好邻居。没测
  • 没有测「加了 cap 的图像单独运行」时的速度。 合成负载里 cap 是无条件税,如果真实情况相同,那么深夜无负载时图像也会一直慢。要上线的话这是必做的补测
  • 窗口是 5〜45 秒,没有覆盖热平衡

Kotonia 将语音 AI、AI 聊天、图像生成和团队协作整合到一个 AI 工作区中。

试用 Kotonia