☰
V100 32G八卡实战:vLLM部署20B模型与吞吐调优全记录
2026/10/17 14:47:14 网站建设 项目流程

接手这批卡之前,团队里的统一口径是“V100 早就该退役了”,但真把 8 张 V100 32G 从机房角落翻出来,插到一台双路服务器上,插电、点亮、跑起大模型推理,我心里很清楚:这并不亏。只要部署方式得当,这 8 张卡的 256GB 显存足够把一个 20B 级别的开源对话模型跑起来,并且能扛住日常内部业务流量。这篇文章就是我做 vLLM 部署和实际业务吞吐测试的完整记录,包含环境配置、启动参数调优、压测脚本设计、结果分析,以及最后让我头疼的若干坑。

适合看这篇内容的读者有两类:一类是手里正好有 V100 或者类似中端存量卡,想低成本组一个内部大模型 API 服务的团队;另一类是已经在别的卡上跑过 vLLM,但想了解不同架构卡上版本兼容、显存规划、并发参数怎么调的人。我尽量不写废话,把能直接抄走的命令和思路都放出来。

1. 内容整体设计与思路拆解

1.1 为什么死磕 V100

V100 的性能放到今天看确实不算强:单卡 FP16 算力大概只有几十 TFLOPS,不支持 BF16,也没有专门的 4-bit 反量化加速单元。但它的 32GB 显存是实打实的,显存带宽也还不错,多卡拼起来的显存总量能撑住中等规模的开源模型。相比买新卡,把这批存量卡用起来几乎是零成本,而且对很多业务场景来说,推理吞吐并不完全依赖峰值算力。

我一开始也犹豫过要不要用 4-bit 量化把模型压缩到更小,比如在某张单卡上跑,但后来实测发现,V100 上的量化推理 kernel 支持并不好,很多优化路径只对新架构的卡生效。更稳的做法是直接用 FP16 精度,用张量并行把模型切到多张卡上,把整卡显存变成 KV cache 和其他中间缓冲区的空间,这个方案可复现性最高。

1.2 卡片拓扑与模型规模的选择

我手上这批卡是 PCIe 接口的 V100 32G,插在两颗 CPU 的机器上,分属两个 PCIe root complex,卡与卡之间没有 NVLink,通信依赖主板 PCIe Switch 和 CPU 间的互连链路。这个拓扑直接决定了张量并行度不能盲目拉满,因为每一轮 forward 计算都需要 allreduce 传梯度之外的数据。

模型方面,我没有选择 70B 级别的模型。70B FP16 权重约 140GB,8 卡确实放得下,但剩余显存给 KV cache 的余地很小,而且张量并行 8 路在 PCIe 拓扑下通信开销较大,最终业务吞吐反而可能不如 20B 档位的模型。我最终切入的是一个约 20B 参数的 decoder-only 对话模型,FP16 权重约 41GB,切成 8 份后每卡只有 5GB 左右的权重,剩下的显存可以大量预留给 KV cache。

提示:跑大模型之前先分清你的瓶颈是显存容量还是通信带宽。显存不够时考虑增加并行度;通信带宽不足时应该降低并行度,或者把模型换小。

1.3 部署目标与吞吐指标

这次部署不追求“能跑通 Demo”,而是要求能承接内部 QA 系统高峰期的真实请求。所以我定的验收指标有三个:

  • 端到端 P95 延迟不超过 12 秒(输出 200 个 token 左右)
  • 并发 32 时系统不出现 GPU OOM
  • 单 GPU 利用率没有出现长期严重不均衡

围绕这三个指标,vLLM 是最合适的选择。它有连续批处理,能把多个请求合并到同一轮 forward 中执行,对显存容量的利用率和整体吞吐帮助非常大。相比之下,传统的一次一个请求的推理方式在这套硬件上会浪费大量显存带宽。

2. 软硬件环境与版本兼容性准备

2.1 能直接跑的软件栈清单

下面的组合是我实际验证过的,不是网上抄的参数:

  • 操作系统:Ubuntu 22.04 服务器版
  • GPU:8 张 V100 32G PCIe,驱动版本 535.104.05
  • CUDA:12.1(通过容器环境使用,方便切换)
  • Python:3.10.x
  • 推理框架:vLLM 0.5.4
  • 模型格式:FP16 Safetensors 格式,存放在本地磁盘,不走挂载的对象存储

这里最关键的一个经验是:vLLM 版本别追新。V100 的 Compute Capability 是 7.0,偏老,而 vLLM 新版本逐渐把旧 GPU 的优化路径挪走或换掉,部分新版本默认的 attention kernel 在 V100 上要么编译失败,要么编译出来性能很差。我最后锁在 0.5.4,后续版本再新也不盲目的升级。

2.2 Volta 架构带来的几个硬限制

先说清楚 V100 有哪些天生不适合新框架的坑,这不是 vLLM 能解决的:

  1. 不支持 BF16。很多新量化模型权重是 BF16,直接加载会出现数据类型不支持的报错。要么转成 FP16,要么明确要求权重本身就是 FP16。
  2. FlashAttention 2 与后续版本不完全适配 V100。FA2 的官方优化主要面向 Ampere 及更新架构,V100 上强制启用容易触发算子缺失。
  3. CUDA Graph 在 V100 上的可用性不如新卡,如果启动时使用默认配置,在加载大模型的时候可能因为捕获图失败或者图占用额外显存而 OOM。

所以我在 vLLM 启动参数里加了--enforce-eager,这个参数的作用是跳过 CUDA Graph,用传统的 eager 模式执行算子。代价是每次请求前几个 token 会有一点算子启动开销,但换来了极大的稳定性,压测时不会被不明原因的显存暴涨打挂。

2.3 vLLM 安装与模型文件落地细节

我用的是 Python venv 安装:

python -m venv /opt/vllm-env source /opt/vllm-env/bin/activate pip install vllm==0.5.4

如果遇到flash-attn编译失败,建议先单独安装flash-attn的旧版本,再去装 vLLM。网上常见的办法是:

pip install flash-attn==2.5.8 --no-build-isolation

但 V100 上我也碰到过这个包编译报错,最后直接选择了不用 FlashAttention,而是依赖 vLLM 自带的 fallback kernel,配合--enforce-eager之后实测稳定。

模型文件不要放在网络盘上首次直接加载,vLLM 加载大模型时要随机读权重文件,网络延迟会把启动时间拉长到几十分钟,而且中途断开会留下残缺缓存。我先把模型完整rsync到本地 SSD 目录/data/models/chat-20b-fp16,然后给运行用户加读权限,这样首次冷启动只需要一到两分钟。

3. vLLM 启动参数与吞吐优化配置

3.1 显存预算分配

显存规划的出发点很简单:模型推理时显存主要分给三块,权重、KV cache、运行时中间激活。权重是固定大小,中间激活跟最大序列长度和 batch 有关,KV cache 则可直接用--gpu-memory-utilization控制。

我的显存分配策略是先按比例留安全余量,再根据压测结果反推调整。启动时我给每张卡留了 8% 的余量,设置--gpu-memory-utilization 0.92,其余空间全部给 vLLM 自管。这样做的原因是不希望某些 kernel 因为显存分配到底而触发 OOM,因为 OOM 一旦发生,整个 API Server 会挂掉,客户端疯狂重试会让故障雪上加霜。

最终启动命令核心部分如下:

python -m vllm.entrypoints.openai.api_server \ --model /data/models/chat-20b-fp16 \ --tensor-parallel-size 8 \ --dtype float16 \ --gpu-memory-utilization 0.92 \ --max-model-len 4096 \ --max-num-seqs 128 \ --max-num-batched-tokens 8192 \ --enforce-eager \ --swap-space 16 \ --disable-log-requests \ --host 0.0.0.0 --port 8000

--swap-space 16的含义是给 CPU 内存预留 16GB 做 KV cache 的换页缓冲,当 GPU 上 KV cache 暂时不够时,把旧序列的 block 换出到内存,而不是直接拒绝请求。这个参数在流量高峰时很重要,但不要给太大,否则换页频繁反而拖慢速度。

3.2 张量并行度与通信拓扑的匹配

8 张卡并不一定要--tensor-parallel-size 8。如果模型只有 10B 左右,8 路张量并行会导致每次计算后都要跨卡同步,通信耗时甚至超过计算耗时,整体速度反而不如 2 卡或 4 卡。对于我的 20B 模型,8 路是合理的,因为切分之后每卡权重降到 5GB,单卡显存负担明显减轻。

我实际压测后发现,由于卡分布在两个 CPU 域,通信时存在一部分跨 NUMA 流量。为了避免 NCCL 在多网卡和多路径通信时反复初始化失败,我在启动环境里设置了:

export NCCL_P2P_DISABLE=1 export NCCL_SHM_DISABLE=1

注意:如果你的机器是带 NVLink 的 SXM 版本,不要设置这两个变量,否则会失去 GPU 直通高速通信能力。我这个是 PCIe 卡,走 CPU 内存中转反而更稳。

这两个环境变量会强制 NCCL 不走 GPU P2P 复制,而是通过 CPU 内存中转,能有效规避某些主板拓扑下 NCCL 初始化卡死和 run 中报错。代价是通信延迟稍微变高,但收益是稳定性。

3.3 影响吞吐的三个核心参数

  • --max-num-seqs:决定 GPU 上最多同时服务的序列数量。设太大会让单个 GPU 的 KV cache 被分得很碎,每个请求分到的 block 有限;设太小则连续批处理的并发优势发挥不出来。我推荐从 64 或 128 起步,压测时看显存占用缓慢调整。
  • --max-num-batched-tokens:控制单次 forward 内最多处理的 token 数。它限制的是 prefill 阶段一次性处理多少个 prompt token,设得越小响应越平稳,但大 prompt 被拆成多轮,整体变慢。设得太大又可能导致显存峰值过高。对于 4K 长度配置,8192 是一个不错的起点。
  • --max-model-len:限制了单条请求的最大 token 长度。很多人喜欢直接拉到 8K 甚至 16K,但序列长度翻倍后,KV cache 占用也接近翻倍,可用并发数会明显下降。业务没有超长上下文诉求时,4096 更合理。

这三个参数其实是互相绑架的,不能单独调。最稳的做法是先固定--max-model-len 4096,再用--max-num-seqs去试并发上限,最后调--max-num-batched-tokens控制峰值显存。

4. 实际业务吞吐测试与结果分析

4.1 测试负载构造方法

我没有用网上常见的随机字符串做压测,因为随机字符串长度和真实用户输入差异大。我从内部 QA 系统里抽样了 200 条真实用户问题,每条都带完整上下文,有些还带有历史轮次,用脚本统一修剪到 256 token 以内。这组 prompt 构成了一个相对真实的输入分布。

输出长度方面,我按业务平均期望输出 200 token 左右,在请求参数里固定配置max_tokens=200。实际生成过程中模型可能在中途遇到结束符停止,会在结果里统计实际 token 数,这比假设每个请求都吃满 200 token 更接近真实。

4.2 压测脚本核心逻辑

压测脚本放在另一台机器上跑,避免压测本身占掉部署机的 CPU 和网络。我使用 Python asyncio + aiohttp 控制并发,核心思路是:维护一个固定个数的信号量,每次从 prompt 列表里随机取一条发起请求,请求完成后立即补充下一条,保证整个测试期间始终有固定数量的请求在处理。

import asyncio import aiohttp import time import random URL = "http://部署机IP:8000/v1/chat/completions" async def worker(session, sem, payload): t0 = time.monotonic() async with sem: async with session.post(URL, json=payload) as resp: resp_data = await resp.json() elapsed = time.monotonic() - t0 first_token = resp_data.get("first_token_time") return elapsed, first_token, resp_data.get("completion_tokens", 0) async def run(concurrency, total_requests): sem = asyncio.Semaphore(concurrency) async with aiohttp.ClientSession() as session: tasks = [] for _ in range(total_requests): payload = build_payload(random.choice(prompt_list)) tasks.append(worker(session, sem, payload)) return await asyncio.gather(*tasks)

这里我用了一个特殊手段来测量首 token 延迟:vLLM 的 OpenAI 兼容接口默认不会返回首 token 时间,但把 Stream 模式关掉时,响应体拿到的完整 JSON 没法直接拆出首 token。所以我实际上用了另一路脚本,用流式响应配合sse解析,记录首包到达时间,同时统计非流式请求做端到端对比。正式压测时,我两种模式都测了,业务大概率会用流式,所以我下面的表格以流式模式数据为准。

4.3 压测结果:并发、延迟与整体吞吐

我跑了几轮压测,每一轮固定跑 10 分钟,让系统先热起来再统计,避免刚启动时算子初始化影响数据。不同并发下的结果如下:

并发数P50 首 token 延迟P50 单流生成速度P95 端到端时间QPS总生成吞吐
1480 ms31 token/s7.8 s0.1331 token/s
81.3 s32 token/s8.2 s0.9256 token/s
162.1 s29 token/s9.5 s1.9464 token/s
323.9 s24 token/s11.8 s3.1768 token/s
647.0 s18 token/s17.5 s4.21152 token/s

先说结论:系统在并发 32 以下表现非常稳定,QPS 基本随并发线性上升,说明此时显存和计算资源没有完全打满。到并发 64 时,QPS 增长放缓,但总生成吞吐还在继续涨,这说明 GPU 已经开始接近饱和,单请求的响应时间也在明显恶化,已经超过我们业务对 P95 延迟不超过 12 秒的要求。所以最终把线上并发上限设在了 32。

4.4 结果分析与瓶颈定位

从表格里能看出两个关键拐点。

第一个拐点是并发 16 到 32 之间,P50 首 token 延迟从 2.1 秒涨到 3.9 秒,几乎翻倍。原因是 prefill 阶段要同时处理更多用户的 prompt,单次 forward 的 token 数增加,排队时间变长。但总生成吞吐从 464 token/s 涨到 768 token/s,说明 decoder 阶段的 batch 效率提升了。

第二个拐点是并发 64,整体吞吐还在涨,但单流生成速度掉到了 18 token/s,用户体感明显变差。这时候 GPU 的计算资源已经不是唯一瓶颈,PCIe 版本的通信带宽、CPU 上做采样和调度的时间,以及内存换页的消耗都开始参与限制。

经验:生产环境的并发上限不要只看 GPU 显存能不能装下,要看 P95 延迟能不能满足业务。宁可让流量排队,也不要无脑调高--max-num-seqs,否则一旦进入换页逻辑,整个集群的响应时间会一起发散。

5. 常见问题与排查实录

5.1 启动即显存溢出和 CUDA Graph 报错

第一次部署时,我遇到的是典型的 OOM:模型权重加载到一半,vLLM 在创建 CUDA Graph 时申请额外显存失败,进程直接退出。最初我以为权重占太多,后来用nvidia-smi看显存,发现每卡实际占用并不高,是 CUDA Graph 的预留空间没有算进gpu-memory-utilization的预算里。

解决办法就是前面提到的加--enforce-eager,同时把--gpu-memory-utilization从 0.95 下调到 0.92。这个参数看起来只是差 3%,但在多卡部署时,3% 可能就是 1~2GB 的安全垫,能挡住不少不稳定情况。

5.2 NCCL 初始化卡死与多卡通信异常

启动 8 卡张量并行时,很容易出现日志停在一行“connecting to ...”卡住不动,或者报NCCL error。这个问题和机器拓扑强相关。我这台机器没有 NVLink,NCCL 走 PCIe 端到端通信时极容易因为驱动、固件、拓扑的组合问题超时。

我的排查顺序是:

nvidia-smi topo -m

先确认卡之间的连接类型。看到所有卡之间是通过PHB或者SYS相连,基本可以判断没有高速 P2P 路径。然后在启动脚本里加入NCCL_P2P_DISABLE=1后重新测试,问题解决。

提示:如果你的是 SXM 版本并带 NVLink,NCCL_P2P_DISABLE=1不要用,否则张量并行吞吐会掉一半以上。这个环境变量只在 PCIe 无 NVLink 的拓扑下才推荐使用。

5.3 长序列请求导致显存占满

压测到中途,我发现只要业务侧出现一个 3000+ token 的长请求,显存占用就会突然跳高,几轮请求后触发 OOM。这是因为连续批处理在 prefill 长序列时,会给该请求一次性分配很多 KV block,如果--max-num-seqs调得很大,数条长序列同时进来,KV cache 瞬间被挤爆。

解决方案有两个,一是限制业务侧最大输入长度,超过上限的请求直接返回提示信息;二是把--max-model-len从 8192 下调到 4096。我最后选择了 4096,因为统计发现业务中超过 4096 token 的上下文不到千分之一,完全可以用提示词截断处理。

5.4 关于量化:V100 不可盲目追新

测试过程中我也尝试过给模型做 4-bit GPTQ 量化,期待能省显存提并发。步骤本身没有报错,但实际起来之后生成速度比 FP16 慢了大约 30%,而且 P99 延迟抖动明显。原因还是 V100 架构对 INT4 反量化算子的支持不够好,vLLM 在 V100 上大概率会回退到通用算子,反量化开销反而拖慢速度。

所以最后生产方案果断放弃了量化,保持 FP16。在 V100 上,用 FP16 + 张量并行 + 足够大的 KV cache,比强行压量化要省心得多。

5.5 首请求慢与 metrics 监控

压测第一轮数据特别差,每条请求从发出到返回响应耗时为 10 秒以上,明显不是正常推理。后来发现在冷启动后第一秒请求会触发 PyTorch 和 vLLM 的算子初始化,底层 kernel 首次调用会缓存很多东西。我处理的方法是压测前先发 20 个普通请求“暖机”,等nvidia-smi里的利用率稳定,再开始正式计时。

vLLM 自带 Prometheus metrics 导出接口,路径在/metrics。我在压测过程中对接了 Grafana,主要关注vllm:num_requests_running、vllm:num_requests_waiting和 GPU 利用率。看到num_requests_waiting在高并发时快速堆积,就说明调度队列已经成了延迟来源。这一步强烈建议在正式业务前做,否则故障定位时完全没有量化数据。

结尾

最后分享一个我自己摸索出来的小技巧:在压测并发的选择上,别上来就跑 64 并发,先用 8、16、32 三组递进,把每组结果画成散点图,观察首 token 延迟的拐点。真正决定系统承接能力的往往是那个拐点,而不是显存理论容量。拿这组 V100 来说,32 并发是实际可用的极限,业务侧限流也按这个值去配置,QPS 虽然不高,但足够稳定。

如果后续模型迭代到更大参数,我大概会先扩到 16 张卡或者切换成 NVLink 环境,否则张量并行通信会成为主要瓶颈。至于这套 V100 方案,放在内部中低峰业务里完全能顶一阵子,也算让这批闲置卡重新发挥了一轮价值。

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

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

立即咨询