[控场AI]
· 9 分钟阅读· 4,525 字

GPUStack vLLM生产部署:KV Cache固定与OOM规避实战

GPUStack vLLM生产部署:KV Cache固定与OOM规避实战

GPUStack调度估算忽略KV Cache显式固定,导致70B模型OOM与吞吐衰减,需固定关键参数并联合监控RSS与TTFT。

本文基于四节点混合GPU集群(A6000与4090)的真实运维经验,剖析GPUStack在vLLM推理部署中的调度估算边界。GPUStack作为放置与生命周期管理层,其VRAM估算仅基于权重体积和声明上下文长度,未考虑激活峰值、CUDA graph捕获开销及实际请求分布。在TP=4部署Llama-3.3-70B时,32k上下文下每序列KV约10.7GB,两个并发长序列即可导致OOM。通过固定max-model-len、gpu-memory-utilization和max-num-seqs等参数可解决。此外,吞吐量在36小时内衰减18%,需联合测量RSS斜率和TTFT p95以区分KV池增长与放置问题。最后指出交互式与批量工作负载共用单端点时max-num-seqs无法同时最优的待解困境。

GPUStack 在自建推理集群中的表现令人满意,但它的调度估算有一个边界:当 vLLM 的 KV Cache 未被显式固定时,OOM 可能在你最意想不到的时刻到来。本文基于一个四节点混合 GPU 集群(A6000 与 4090)的真实运维经验,拆解从 70B 模型 OOM 到吞吐衰减的完整排查路径。

GPUStack vLLM 生产部署 KV Cache 问题

GPUStack 的定位与边界

GPUStack 本质上是一个放置与生命周期管理层,而非推理引擎。部署模型时,它选择一个工作节点,在底层启动 vLLM、SGLang 或 llama-box,并代理请求。对于希望以“每端点一模型”方式运行、且不想手写调度器代码的团队,这一形态非常契合。

问题出在放置决策所依赖的 VRAM 估算上:该估算基于权重体积加上声明的上下文长度,完全不考虑激活峰值、CUDA graph 捕获开销,也不了解实际请求分布。如果接受默认值,你信任的是一个从未被设计为运行时保证的数字。据原文作者所述,他们的四节点集群在替换掉手写 docker run 包装脚本后,两个月内运行稳定,但有一个特定的失效边界值得记录。

KV Cache(键值缓存) 是大语言模型推理中的核心内存结构。在自回归生成时,模型需要为每个已处理的 token 保存 Key 和 Value 张量,以便后续 token 计算注意力时复用,避免重复计算。其大小与层数、KV 头数、头维度、序列长度、并发序列数以及数据类型直接相关。公式为:每 token KV 字节数 = 2(K 和 V)× 层数 × KV 头数 × 头维度 × 每元素字节数。以 Llama-3.3-70B 为例,80 层、8 个 KV 头、128 维、bf16 下每 token 约 320 KB。KV Cache 通常占据推理显存的很大一部分,尤其在长上下文场景下可能超过模型权重本身。vLLM 通过 PagedAttention 管理 KV Cache,将其划分为块以提高内存利用率,但池的容量仍需在启动时通过 max-model-len 和 max-num-seqs 等参数间接约束。

70B 模型的 OOM 事故复盘

在四张卡上以 TP=4 部署 Llama-3.3-70B(bf16),并保留模型默认的 max-model-len。放置成功,部署报告健康。然而在真实流量下——长系统提示、6k 到 8k token 的请求——工作节点逐个 OOM,GPUStack 随即重启它们。表面看像是调度器不稳定,实际上是算术问题。

Llama-3.3-70B 的 KV 开销约为每 token 320 KB:80 层 × 8 个 KV 头 × 128 头维度 × 2(K 和 V)× 2 字节。在 32k 上下文下,单个序列的纯 KV 就约 10.7 GB,而每卡权重已占约 35 GB。两个并发长序列就足以让显卡耗尽显存。估算器将上下文视为一个声明预算,而非“每序列分配 × 并发数”的乘积。

需要固定的关键参数

修复方式并不复杂。固定 max-model-len,将 gpu-memory-utilization 设到 graph capture 仍能容纳的阈值以下,并限制 max-num-seqs 以防止 KV 池被超额订阅。原文给出了一份经过加固的 vLLM 启动参数:

vllm serve meta-llama/Llama-3.3-70B-Instruct \
  --host 0.0.0.0 --port 8000 \
  --tensor-parallel-size 4 \
  --max-model-len 16384 \
  --gpu-memory-utilization 0.88 \
  --kv-cache-dtype fp8 \
  --max-num-seqs 32 \
  --enable-chunked-prefill \
  --swap-space 0 \
  --disable-log-requests

其中真正影响稳定性的只有四个:

  • --gpu-memory-utilization 0.88:在 0.92 时相同权重会 OOM。TP=4 下的 graph capture 并不像人们假设的那样能塞进剩余余量。
  • --max-model-len 16384:将 KV 上限减半。先测量真实提示的 p99,原团队测量值为 11k,因此 16k 既留出余量又不至于让池翻倍。
  • --swap-space 0:刻意禁用主机交换。交换会把一次诚实的崩溃转变成 40 倍的延迟断崖,在凌晨两点看起来像网络故障。
  • --kv-cache-dtype fp8:KV 减半,在其评估子集上无可测量的退化。长上下文检索的退化比聊天更明显,启用前需测试。

在无法使用 TP 的异构节点上,GPUStack 回退到 llama-box 与层卸载。相同的算术,不同的旋钮:num_gpu 60(共 80 层)在 48 GB 显存上是诚实的上限;设为 80 会得到一个能加载但在第一个 4k 提示时就崩溃的模型。

CUDA graph capture 是 vLLM 等推理引擎用于降低内核启动开销的优化技术。在启动或首次运行时,引擎会捕获一系列 CUDA 操作形成图,后续执行时直接重放,从而减少 CPU 与 GPU 之间的同步和调度开销。然而,捕获过程本身需要额外的显存来存储图结构及其中间状态,且捕获的图尺寸通常与批次大小相关。当 gpu-memory-utilization 设置过高时,KV Cache 池会占用几乎全部剩余显存,导致 graph capture 无法获得所需空间而触发 OOM。因此,在 TP=4 等并行配置下,需要为 graph capture 预留余量,这也是文中将 gpu-memory-utilization 从 0.92 降至 0.88 的原因之一。

测量 TTFT 与缓慢泄漏

第二个问题更隐蔽:吞吐量在 36 小时内衰减约 18%,而 GPU 利用率保持平稳。这种模式要么是抢占抖动,要么是 KV 块增长,唯一区分方式是同时测量 TTFT 与 RSS。原文提供了一段异步流式测量脚本,核心逻辑是记录 TTFT、总耗时与字符数,并在多轮浸泡测试中打印 RSS 增量与 TTFT p95。

关键洞察在于:绝对 TTFT 不是信号。信号是跨轮次的 RSS 斜率以及TTFT p95 是否独立于供给负载而漂移。RSS 单调上升且 p95 跟随,意味着 KV 池增长;p95 上升而 RSS 平坦,意味着放置或抢占问题。原团队遇到的是前者:长短差异极大的提示长度导致块碎片化。通过固定 --max-num-seqs 32 并基于 RSS 斜率阈值(而非固定 cron)回收工作节点解决。

为获得对照,原团队将同一脚本指向 B-Lost 的中继(https://b-lost.com/v1),使用相同模型名。由于该中继在单个 OpenAI 兼容接口后聚合多个提供商,它提供了一个不受本地节点状态污染的延迟参考。在最近一次浸泡中,通过中继走回退路径使 TTFT p50 降低了 35 ms(绕过了降级节点),更有用的是其 p50 在整个运行期间保持在 35 ms 带宽内,而本地节点则向上漂移。这证实了衰减是本地问题。

TTFT(Time To First Token) 是衡量推理服务响应速度的关键指标,指从请求发出到收到第一个输出 token 的时间。它主要受预填充阶段(prefill)影响,包括提示处理、注意力计算和调度延迟。在流式响应中,TTFT 反映了用户感知的初始等待时间。RSS(Resident Set Size) 是进程实际占用的物理内存大小,在 GPU 场景中通常指显存占用。监控 RSS 随时间的变化可以揭示内存泄漏或缓存增长问题。文中强调,绝对 TTFT 值受输入长度、模型状态和并发影响,单独看意义有限;而跨轮次的 RSS 斜率(持续上升)与 TTFT p95 的漂移趋势结合,才能区分是 KV 池碎片化增长还是调度抢占导致的性能衰减。

尚未解决的困境:两种工作负载,一个端点

正确设定 max-num-seqs 需要了解工作负载,而原团队用同一个端点服务两种场景:交互式聊天 UI(短提示、高并发)与夜间批量摘要任务(30k token 提示、近乎零并发)。拆成两个部署会使常驻权重翻倍;保持一个部署则会给其中一种工作负载错误的池形状,而他们已经知道哪一方会输。按请求在两个池之间路由是显而易见的答案,但增加了一跳,目前没有预算妥善调试。原文作者在文末向社区提问:如果有人在未增加节点的情况下解决了这个问题,希望听到你把队列放在了哪一侧。

max-num-seqs 是 vLLM 中控制同时处理的序列数量的参数,它间接决定了 KV Cache 池的并发容量。较大的值允许更多请求并行,提高吞吐,但每个序列都需要独立的 KV 空间,可能迅速耗尽显存;较小的值则限制并发,降低吞吐但保证稳定性。在混合工作负载场景下,短提示高并发的交互式请求需要较大的 max-num-seqs 以维持低延迟,而长提示低并发的批量任务则需要更长的 max-model-len 和较少的并发序列。单一端点只能选择一个折中点,无法同时满足两种需求。按请求路由到不同池是理论上的解决方案,但引入额外跳数和运维复杂度,需要权衡队列位置、资源隔离和成本。

核心要点

  • GPUStack 的调度估算基于权重体积与声明上下文长度,不包含激活峰值、graph capture 或真实请求分布,不能作为运行时保证。
  • 70B 模型在 TP=4 下,32k 上下文每序列 KV 约 10.7 GB,两个并发长序列即可压垮单卡;固定 max-model-len、gpu-memory-utilization 与 max-num-seqs 是必要手段。
  • gpu-memory-utilization 0.88 是 TP=4 graph capture 的实测安全线,swap-space 0 可避免把崩溃转化成延迟断崖。
  • 吞吐衰减排查应同时观察 RSS 斜率与 TTFT p95:RSS 单调上升指向 KV 池增长,p95 独立漂移指向放置或抢占问题。
  • 交互式与批量工作负载共用单端点时,max-num-seqs 的池形状无法同时最优,按请求路由是待解工程问题。
分享:

相关推荐