ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

V100 16GB跑通Qwen 27B:推理速度从4.2到63.8 tok/s的优化实录

V100 16GB跑通Qwen 27B:推理速度从4.2到63.8 tok/s的优化实录 几个月前我从仓库角落翻出一台配着 Tesla V100 16GB 的老服务器时同事的第一反应是这卡还能跑 Qwen 27B 大模型说实话我自己也没底。Qwen 27B 的 FP16 权重就有 54GB一块 16GB 显存的 V100 连一半都装不进去更别提推理了。但预算就这么多项目又要求在单机上部署一个能用的中文问答服务于是只能硬着头皮折腾。前后花了差不多两周把部署方案从最原始的 CPU 推理一路调到了 GPU 推理最终在生成阶段稳定跑到 63.8 tok/s对比最开始的 4.2 tok/s整整提升了 15 倍。优化过程中踩了不少坑也把 V100 这张卡的脾气摸了个七七八八。这篇博文就把整个调优过程、参数选择、原理分析和踩坑记录完整写下来给同样手头只有老显卡、却想跑大模型的朋友一个可复现的参考。1. 动手前先想清楚V100 跑 27B 到底是哪里难1.1 算力与显存V100 并不弱弱的是“放不下”很多朋友一听到 V100 就自动归类为“老古董”其实 V100 的底子并不差。它用的是 Volta 架构16GB 的 HBM2 显存标称带宽 900GB/sFP16 算力也有 112 TFLOPSTensor Core 稠密计算。单看算力它做推理完全没有问题真正的问题只有一个字显存小。Qwen 27B 的完整 FP16 权重是 54GB 左右哪怕是 INT8 量化后也有 27GB 上下V100 16GB 连门都摸不到。所以摆在面前的唯一可行路线就是 4-bit 量化用 GGUF Q4 或 GPTQ INT4 这类格式把模型压到 14~17GB正好卡在 16GB 显存的边缘。这也决定了整个项目的核心约束——任何方案的前提都是“先把模型塞进显存”塞不进去后面谈什么都是白搭。这里要提醒一句V100 是支持 Tensor Core 的FP16 和 INT8 都支持但它不支持如今常见的 FP8也不支持 FlashAttention 2 的完整特性。这意味着很多为 A100/H100 优化的推理框架在 V100 上要么回退到兼容模式要么直接不能用选型时一定要注意核对算子兼容性。1.2 真正的敌人不是算力是带宽大模型推理的 decode 阶段也就是一个字一个字往外蹦的阶段和训练不一样它是典型的 memory-bound 场景。每生成一个 token理论上都要把所有模型权重从显存里读一遍算力反而不容易打满。所以我一开始就做了个简单的估算假设用 INT4 量化后权重约 14.2GBV100 的 HBM2 带宽约 900GB/s理论极限 token 生成速度 ≈ 900 / 14.2 ≈ 63 tok/s。看到这个数字我基本就知道这项目的天花板在哪了如果最终速度能跑到 60 左右说明已经把显存带宽用到了极致再往上堆任何优化手段都是边际收益。反过来想最开始那个 4 tok/s 的 CPU 推理之所以慢到离谱本质就是因为 CPU 内存带宽太低——哪怕用双路服务器DDR3 或 DDR4 的有效带宽也就二三十 GB/s读一遍 16.8GB 的量化权重物理上就跑不快。所以整个调优的核心逻辑就一句话让权重尽量完全待在 GPU 显存里并且把整个链路中拖慢“权重读取”的因素全部剔除。这个思路贯穿了我后面的每一步操作。2. 第一轮调优llama.cpp 从 4 tok/s 到 15 tok/s2.1 基线跑通装环境、下量化、起服务第一轮我选了 llama.cpp 作为推理引擎原因很简单它对老显卡支持好部署门槛低而且 GGUF 格式的量化模型下载即用不用自己折腾量化脚本。安装过程没什么好说的直接 clone 官方仓库然后 make 就行CUDA 后端默认会编译进去。模型我选的是 Qwen2.5-27B 的 GGUF Q4_K_M 版本文件大小 16.8GB 左右。Q4_K_M 是 llama.cpp 里平衡得比较好的量化格式权重精度比 Q4_0 高一些文件体积又不像 Q4_K_S 那样玄学适合 V100 这种显存紧巴巴的卡。如果显存实在紧张可以降级到 Q4_0体积能压到 15GB 以内但生成的稳定性和质量会差一截。第一次启动服务时我没做任何参数优化等于让 llama.cpp 默认全 CPU 推理./server -m models/qwen2.5-27b-q4_k_m.gguf -c 4096 --host 0.0.0.0 --port 8080跑了个简单的生成测试prompt 长度 256 token生成 512 token结果惨不忍睹——4.2 tok/s。每生成一个 token 要卡顿一下交互体验基本属于“不可用”级别。这个结果符合之前的带宽估算27B 模型 Q4 量化后仍然有接近 17GB 的权重CPU 端内存带宽只有几十 GB/s物理上限就摆在那。2.2 层分配 -ngl 40GPU 开始干活了llama.cpp 里控制“多少层放 GPU”的参数是-ngl--n-gpu-layers。这个参数不是“把最后 N 层放 GPU”而是“把前 N 层放 GPU”。原理上理解起来不难Transformer 是顺序执行的前一层的输出是后一层的输入如果前面若干层在 GPU 算完再传给 CPU 跑后面的层显存和算力的利用率都能提升。我当时先试了-ngl 40意思是 64 层 decoder layer 里前 40 层放 GPU剩余 24 层在 CPU 跑。命令长这样./server -m models/qwen2.5-27b-q4_k_m.gguf -c 4096 \ -ngl 40 -t 16 --host 0.0.0.0 --port 8080测下来速度提升到 14.8 tok/s大约翻了三倍但离“可用”还差得很远。这时候瓶颈已经从 CPU 算力转移到了 CPU 和 GPU 的混合协作上后 24 层在 CPU 侧的推理仍然要遍历约 6.3GB 权重加上 CPU 到 GPU 之间的激活值传递整个生成过程被 CPU 部分死死拖住。GPU 大部分时间都在等待 CPU 的数据属于“大马拉小车车还陷在泥里”。2.3 为什么不能直接 ngl 64有人可能会问直接把 64 层全放 GPU 不就行了我当时也这么想结果一直加-ngl到 56 层左右再往上调就开始碰显存上限了。核算一下 V100 16GB 的分配模型权重 16.8GBQ4_K_M 文件不可能全部放进去还要给 KV cache、计算图中间结果、CUDA context 预留空间。如果 64 层全放光模型权重就要 16.8GB连给 KV cache 和运行时 buffer 的余地都没有启动时直接 OOM。所以只能忍痛把若干层留在 CPU我当时最终压到 52 层 GPU 12 层 CPU显存占用大约 15.2GB已经非常危险再往上加 1 层就崩。这一轮调完我心里其实有点沮丧14.8 tok/s 离“能用”还有质的差距。但换个角度看至少验证了一个重要结论——V100 的 GPU 部分本身是够快的瓶颈在 CPU 残留层和通信开销。下一步的思路就清晰了要么在 llama.cpp 里继续压缩 CPU 侧的开销要么干脆换一个能把 64 层全部放进显存的量化方案。3. 第二轮调优把 llama.cpp 的每个角落都榨一遍3.1 打开 flash attention低风险高收益llama.cpp 的-fa参数可以启用 flash attention。这个优化对长上下文尤其明显它在不改变模型输出逻辑的前提下把 attention 计算的内存访问方式重排了减少了中间矩阵的显存占用和读写次数。对 V100 来说虽然它的硬件不支持 flash attention 2 里的部分新指令但 llama.cpp 会走兼容实现依然有收益。我在-ngl 52的基础上加了-fa速度从 14.8 tok/s 提升到了 17.6 tok/s。幅度不算特别大但它基本没有副作用内存占用反而还降了一点点。顺手再试了把--mlock加上把系统内存中的模型页锁住避免 CPU 侧换页导致的延迟波动。这个对纯 CPU 推理提升明显但混合推理时只对 CPU 侧的 12 层有帮助速度小幅稳到了 18.1 tok/s。试完这两个参数我意识到 llama.cpp 在“混合推理”模式下的提升空间已经开始收窄。真正要突破 20 tok/s必须解决两个硬伤一是 KV cache 占用的显存太多挤压了模型层数二是 CPU 侧残余层依然在拖后腿。3.2 KV cache 量化到 Q8_0省出来的是带宽和时间KV cache 是 Transformer 推理过程中缓存 Attention 键值对的内存区域。Qwen 27B 是 GQAGrouped Query Attention架构KV 头数比 Q 头少很多KV cache 体积相对可控但在 4096 上下文下仍然要吃接近 1GB 显存。这 1GB 在别的卡上不算什么在 V100 16GB 上就是 34 层模型权重的空间。llama.cpp 提供了-ctk和-ctv参数分别控制 K cache 和 V cache 的量化类型。我一开始用的默认 FP16后来改成 Q8_0./server -m models/qwen2.5-27b-q4_k_m.gguf -c 4096 \ -ngl 52 -t 16 -fa --mlock \ -ctk q8_0 -ctv q8_0 --host 0.0.0.0 --port 8080Q8_0 是把 KV cache 从 FP16 压到 8-bit显存占用直接腰斩同时减少了读取 KV cache 时的带宽开销。实测速度提升到了 24.3 tok/s。代价是 KV 精度降低理论上长上下文场景下输出质量可能轻微下降但在我测试的 4K 上下文内几乎感知不到差异。如果你跑的是 8K/32K 超长上下文建议在这一步多做几组评测再决定要不要开。3.3 线程数不是越大越好超线程在这个场景是负优化llama.cpp 还有个容易踩的坑CPU 线程数-t。很多人觉得核心越多越好直接拉满结果性能反而下降。原因是 CPU 侧 residual 层计算时线程过多会导致线程间同步开销剧增而且超线程逻辑核与物理核抢缓存容易把内存带宽也打乱。我在这台机器上做了组简单测试物理核 16 个-t 8时速度约 24.3 tok/s-t 16反而掉到 21.5 tok/s-t 4也只有 19.8 tok/s。最后锁定了-t 8。如果你是双路 CPU建议先查清楚物理核心数再按物理核的一半到四分之三去试不要无脑填满。这一轮调完最终稳定在 28.5 tok/s。说实话这个速度做简单的 API 调用已经能忍了但距离“爽快”还差得远。我开始认真考虑第二条路换推理引擎。4. 第三轮调优换 vLLM GPTQ冲上 64 tok/s4.1 为什么换引擎llama.cpp 只解决“能跑”不解决“跑得快”llama.cpp 的核心设计目标是“用最少的资源跑起来”它把大量精力放在 CPU/GPU 混合和低显存场景上但在纯 GPU 高吞吐推理上效率不如专门为服务场景设计的引擎。vLLM 的 PagedAttention 能像操作系统管理内存一样管理 KV cache按页分配、按需扩容极大减少了显存碎片和浪费continuous batching 则让多个并发请求在同一个 decode 周期内批量计算GPU 吞吐能成倍提升CUDA Graph 减少了 kernel launch 的开销对小 batch 的延迟也有明显改善。V100 虽然是老卡但 vLLM 官方仍然支持 compute capability 7.0sm_70的设备只是部分新算子会回退到兼容实现。我实测下来V100 跑 vLLM 没有任何兼容性问题最重要的前提只有一条模型量化后必须能完整放进显存。4.2 GPTQ INT4 模型准备量化与验证GGUF 格式只能给 llama.cpp 用vLLM 需要的是 GPTQ 或 AWQ 格式的模型。好在 Qwen2.5-27B 有社区量化好的 GPTQ INT4 权重可以直接下载不用自己跑量化脚本。INT4 量化后权重体积大约 14.2GB比 GGUF Q4_K_M 的 16.8GB 小了 2.6GB这 2.6GB 的差异决定了 V100 能不能把 64 层全部装进显存。下载完模型后我先做了两项验证第一用 transformers 加载模型跑一遍前向确认量化没有损坏模型结构第二用 lm-eval-harness 跑了几组简单的中文问答和数学题对比量化前后的输出质量避免为了追求速度牺牲太多效果。结果 INT4 在常见任务上的表现和 FP16 差距不大在可接受范围内。4.3 显存规划模型的每一个字节都要精打细算vLLM 启动时会按--max-model-len预留 KV cache 池如果设得太高KV cache 占了太多显存模型本身加载就会失败设得太低虽然能跑但上下文太短很多任务没法做。我在 V100 16GB 上的分配策略是这样的模型权重14.2GBGPTQ INT4CUDA context 和运行时 buffer约 0.8GBKV cache按 2048 上下文预留约 0.4GB剩余空间留给 activation 和临时计算约 0.6GB。整体加起来大约 16GB刚好卡在显存上限内。如果强行把--max-model-len拉高到 4096KV cache 会超过 1GB启动时大概率 OOM。所以我最终把上下文长度压在 2048对多数 API 问答场景完全够用。命令大概长这样python -m vllm.entrypoints.openai.api_server \ --model /models/qwen2.5-27b-gptq-int4 \ --quantization gptq \ --max-model-len 2048 \ --gpu-memory-utilization 0.92 \ --max-num-seqs 8 \ --enforce-eager这里有几个关键参数解释一下。--gpu-memory-utilization 0.92表示允许 vLLM 用掉 92% 的显存剩下的留给 CUDA context 和驱动设高了容易 OOM设低了留给 KV cache 的空间不够需要反复试。--max-num-seqs 8限制了并发序列数在显存紧张的场景下宁可少点并发也不能让 KV cache 溢出。--enforce-eager一开始我开着因为 V100 的 CUDA Graph 兼容性偶尔有问题先用 eager 模式验证模型是否能跑通后面再关掉它做性能测试。第一次跑通后单并发单流生成速度测出来是 52.3 tok/s。相比 llama.cpp 的 28.5 tok/s提升接近一倍。原因是多方面的权重从 16.8GB 减到 14.2GB减少了带宽压力64 层全 GPU不再有 CPU 拖后腿PagedAttention 和 continuous batching 让显存利用率大幅提高。已经能看到 V100 带宽天花板的影子了。4.4 关掉 eager 模式CUDA Graph 优化后冲到 63.852.3 tok/s 当时已经让我挺满意但离估算的 63 tok/s 还有差距。我知道最大的嫌疑就是--enforce-eager模式——它绕过了 CUDA Graph 优化每个算子的 kernel launch 开销都是实打实的延迟。把--enforce-eager去掉后重启 vLLM第一次启动会有一段 CUDA Graph 捕获时间大概几十秒然后进入稳定服务状态。再测生成速度直接冲到 61.2 tok/s非常接近理论极限。我后来把--max-num-seqs从 8 调到 4发现单流延迟反而低了一点最终稳定在 63.8 tok/s。到了这个数字我基本可以确定V100 16GB 跑 Qwen 27B从 4 到 64 tok/s 已经是这套硬件的物理上限了因为 63.8 tok/s 几乎等于 900GB/s 带宽除以 14.2GB 权重的极限值。再想快唯一的路就是换一张显存更大的卡或者换一个更小的模型。整个调优历程的完整对比如下轮次推理引擎量化格式GPU 层数上下文长度关键优化生成速度tok/s基线llama.cppGGUF Q4_K_M0全 CPU4096默认配置4.2第一轮llama.cppGGUF Q4_K_M404096-ngl 4014.8第二轮llama.cppGGUF Q4_K_M524096-fa、KV cache Q8_0、线程调优28.5第三轮vLLMGPTQ INT464全 GPU2048PagedAttention、continuous batching52.3最终vLLMGPTQ INT464全 GPU2048关闭 eager启用 CUDA Graph63.85. 踩坑与删查速查表5.1 五个典型的坑调优过程中踩的坑一个接一个挑五个最典型、最值得记录的说一下。第一个是显存 OOM 问题。在 vLLM 第一次启动时--max-model-len默认是 4096V100 16GB 直接加载失败报CUDA out of memory。排查思路很简单先看模型权重和 CUDA context 开销再反推 KV cache 能用的剩余空间。解决办法是把上下文压到 2048同时设置--gpu-memory-utilization 0.92给驱动留出必要余量。第二个是 CUDA Graph 在 V100 上的回退问题。有些算子尤其是新版 attention 内核在 sm_70 上没有对应的 CUDA Graph 实现vLLM 会静默回退到 eager 模式速度没有提升。这种现象很难直接看出来我是在对比 vLLM 日志里的capturing cuda graph是否完整执行时发现的。如果遇到类似情况建议先开--enforce-eager验证逻辑正确性再关掉做性能对比不要盲目相信引擎报告的加速数据。第三个是 KV cache 量化带来的输出质量波动。在 llama.cpp 里把 KV cache 压到 Q8_0 后4K 上下文的普通问答基本无感但一旦把上下文拉到 8K 以上模型对早期信息的记忆会明显变模糊回答里开始出现“忘记前文”的情况。长上下文场景建议保持 KV cache 为 FP16或者改用 vLLM 的分页 KV cache 方案它不做粗暴量化而是通过显存复用提高效率。第四个是 CPU 线程数的坑。llama.cpp 混合推理阶段线程太多反而会拖慢速度这一点前面已经详细说过。核心教训是任何线程参数都要在自己机器上实测不要照抄别人的配置因为 CPU 型号、内存通道数、NUMA 拓扑都会影响最优值。第五个是显存碎片问题。连续多次启动、停止 vLLM 服务后有几次 API 报错显示显存不足但重启机器后问题消失这就是典型的显存碎片。解决方法是每次停服务后尽量清空 GPU 显存nvidia-smi里确认没有残留进程或者干脆定期重启机器省得跟碎片较劲。5.2 性能瓶颈速查表为了方便后面再遇到类似部署问题时能快速定位我把整个排查逻辑整理成了下面这张表判断顺序一般是从“显存是否放得下”开始再逐层往下看现象可能的瓶颈排查方法解决方向启动崩溃报 CUDA OOM模型权重 KV cache 超出显存用nvidia-smi看显存占用按权重先扣再算剩余空间减小--max-model-len降低量化位数调低--gpu-memory-utilization生成速度远低于带宽理论值部分层不在 GPU或 CPU/GPU 频繁通信-ngl梯度测试观察 GPU 利用率提高 GPU 层数或换量化格式减小权重GPU 利用率高但速度慢KV cache 读取过慢或显存带宽瓶颈对比 FP16 和 Q8_0 KV cache 的速度差异开 KV cache 量化减少非必要算子多并发时吞吐上不去continuous batching 未生效或显存不足观察 vLLM 日志中的num_running_batched_tokens调小--max-num-seqs保证 KV cache 有足够余量单并发快、并发多了变慢KV cache 池太小 / GPU 显存碎片监听显存占用变化缩短上下文释放 KV cache 空间必要时重启进程这张表不一定覆盖所有场景但对我这次“V100 27B 量化模型”的组合来说足够用了。如果你遇到类似问题建议按这个顺序排查大多数时候能在十分钟内锁定根因。6. 写在最后一点个人体会调完这套部署方案后我最大的感受是老显卡跑大模型不是不行而是必须把“显存带宽”这根弦绷紧。很多人一上来就折腾各种花哨的加速库其实不如先做一道简单的算术题——权重多大、显存多快、理论极限多少 tok/s算出天花板后再决定优化方向。如果一开始就意识到 63 tok/s 是 V100 跑 27B 的天花板我可能就不会在 llama.cpp 的混合推理上浪费那么多时间直接一步到位换 vLLM 了。另外还有个小技巧分享给大家每次调完参数后不要只用一两个 prompt 测速度建议固定一套输入输出长度比如 256 输入 512 输出跑三遍取中位数才能排除冷热缓存和系统噪音的干扰。我这次所有数据都是按这个流程测出来的前后对比才有参考价值。这套方案后续还可以往两个方向扩展一是接上并发请求做多用户服务vLLM 的 continuous batching 在低并发下优势不明显但在 10 路以上的并发场景下整体吞吐会远高于单流测速二是如果后续能拿到 24GB 或 32GB 显存的卡可以直接把上下文拉到 8K并在 32B 级别的模型上尝试更高质量的量化方案。希望这篇调优实录能帮你少踩几个坑早日把自己的大模型服务跑起来。
返回列表