1. 项目背景:一块 V100 和 27B 大模型的组合
1.1 需求画像:为什么非要拿 V100 跑大模型
先交代一下背景。我在项目里拿到一批存量 V100 32GB,想做一个私有化部署的大模型问答服务,模型选型定在 Qwen 27B 的指令版本。第一反应是:这组合能跑吗?27B 模型光是 FP16 权重就要 54GB 左右,一张 V100 满配才 32GB,连司机的腿都放不下。但换个思路——把权重量化到 4-bit 左右,Q4_K_M 格式大约 17GB,这就有机会塞进 V100 了。
V100 是 Volta 架构的老卡,发布于 2017 年,compute capability 是 7.0,SXM2 版本有 32GB HBM2,显存带宽约 900GB/s。它不是不能跑大模型,只是特别挑场景。它没有像 Ampere 那样的 BF16 支持,INT8 Tensor Core 也很弱,所以在 V100 上跑大模型,能用的是 FP16 Tensor Core 和它的高带宽 HBM2。换句话说,V100 就是个“内存带宽很强但没有新指令集”的显卡,用对工具照样能干活。
这篇文章面向谁?两类人。一类是手里有存量 V100、被要求“本地跑个大模型”的运维和算法工程师;另一类是刚开始接触本地部署,想搞明白量化、KV Cache、Flash Attention 这些参数到底怎么影响速度的新手。我会把从 4 tok/s 到 64 tok/s 的每一步、每个参数为什么这么调、每个坑是什么,全写出来。
1.2 速度指标到底意味着什么
4 tok/s 是什么体验?你说一句“你好”,模型要酝酿将近一秒才吐第一个字,然后一个字一个字往外蹦,一分钟只能憋出 240 个 token,相当于一条中等长度的微信长文。这种速度做聊天是折磨,做代码补全更不可能,Agent 场景里一个工具调用来回能等半分钟。
64 tok/s 是什么体验?这就是目前商业 API 中等速度档位的感觉,读到流畅,聊天基本无感。首 token 延迟从刚才的 1.5 秒降到 100 毫秒级别。如果说 4 tok/s 只能做离线批处理,那么 40+ tok/s 已经可以支撑小规模实时交互了。
我最终的配置是这样的:Qwen 27B 指令版,Q4_K_M 量化,单张 V100 32GB,上下文控制在 4096,KV Cache 用 Q8_0 量化,短上下文峰值能到 64 tok/s,长对话稳定在 50 tok/s 左右。接下来就一步步拆解这个过程。
2. 为什么速度能提高这么多:先搞清楚瓶颈
2.1 自回归解码的本质是“显存带宽游戏”
大模型生成 token 是自回归的,每生成一个 token,都要把模型的所有权重完整读一遍,做一次前向传播。这个过程中,计算量虽然不小,但在显卡上通常不是瓶颈,真正卡脖子的是显存带宽。一块显存带宽 900GB/s 的 V100,如果模型权重是 27B 的 FP16,也就是约 54GB,那么理论上每秒钟最多读取 16 遍权重,也就是说 decode 速度的极限大概是 16 tok/s。你看,哪怕是最原始的全精度,理论极限也没低到 4 tok/s,所以一开始跑出 4 tok/s,问题一定出在别的地方。
换成 Q4_K_M 量化之后,权重降到 17GB 左右。900GB/s 除以 17GB,理论极限大约是 52 tok/s。再算上 KV Cache 的读取和 kernel 自身开销,实际能跑到 45~55 tok/s 已经非常正常。我最终测到的长对话稳定值在 50 左右,短上下文瞬时峰值能冲到 64,是因为量化权重的有效读取量在某些 kernel 路径下会更低,加上预填充和解码阶段有重叠,卡得好一点能到 60 以上。这个数字可能不同版本有差异,但量级是对的。
所以这次调优的核心思路就一条:把每生成一个 token 必须读的数据量压到最低,并确保这些数据都在显存里,而不是在 CPU 内存里。后面所有参数调整都是围绕这个思路展开的。
2.2 量化档位选型:不是越低越好
我先把不同量化档位在 27B 模型上的体积列个大概,注意不同模型和量化实现会有浮动,但量级可以参考。
| 量化档位 | 文件体积约 | 相对 FP16 质量 | 解码速度参考 | V100 32GB 能否全量放显存 |
|---|---|---|---|---|
| Q8_0 | 约 28GB | 接近无损 | 相对较慢 | 勉强,需压缩上下文和 KV |
| Q6_K | 约 22GB | 很好 | 中等 | 可以 |
| Q5_K_M | 约 19GB | 推荐档 | 中等偏快 | 可以 |
| Q4_K_M | 约 17GB | 日常够用 | 最快档之一 | 可以 |
| Q3_K_S | 约 13GB | 有可见下降 | 快 | 可以,适合 16GB 卡 |
Q4_K_M 是我的最终选择。它在 27B 这个规模上,数学、代码、中文理解都还能保持不错的水平,文件又足够小,解码速度快。Q5_K_M 质量更保险,但文件要大 2GB 左右,带宽占用多 10%,速度会降到 45~50。Q8_0 基本无损,可 28GB 权重加上 KV Cache 和 CUDA 上下文之后,32GB 显存非常紧张,实际速度反而不快,因为你得把上下文砍到很小,还要放弃很多优化空间。
有人会问为什么不用更低位的 Q3。我的看法是:如果显存实在不够,Q3_K_S 是 16GB 卡的最后选择,但日常使用能明显感觉到模型“变笨”,尤其是一段话里的逻辑推理和长文本约束,容易出现漏细节。所以只要显存够,我建议至少 Q4_K_M。
3. 环境准备:llama-server 版本和 CUDA 编译
3.1 推理框架选型:为什么是 llama.cpp 而不是 vLLM
在 V100 上部署 27B 模型,框架选择很重要。vLLM 是很火,但它在 Volta 架构上并不是最优解。V100 不支持 BF16,INT8 Tensor Core 也基本等于摆设,vLLM 很多新特性需要较新的 GPU 架构,强行在 V100 上编译 FlashAttention 支持会折腾很久,而且单卡小显存场景下,vLLM 的显存管理优势发挥不出来。
ExLlamaV2 也很强,但它更偏向新架构的量化推理,对 V100 的兼容性一般。所以最后我选了 llama.cpp 的 llama-server。这套方案的好处是:GGUF 量化格式在 CPU/GPU 上都有完善实现,对老架构的 CUDA 适配做得很好,而且内置 OpenAI 兼容 API,部署完直接能用 HTTP 接口调用。
llama.cpp 版本我建议直接用较新的 release 分支。GGUF 格式一直在演进,太老的二进制可能加载不了新模型,太新的模型也可能要求升级代码。选一个稳定版本,然后在部署脚本里固定 commit 号,避免过段时间拉更新后行为变化。
3.2 从源码编译出 V100 可用的 llama-server
有人会直接用官方预编译的 llama-server,但那个包不一定包含 CUDA 支持,或者没针对 V100 编译。因为在 cmake 阶段如果不指定架构,构建脚本可能在 GPU 检测时选错目标,或者只编译一个通用版本,导致 V100 上性能很差甚至跑不起来。我的做法是源码编译,并且明确指定 CUDA 架构。
git clone https://github.com/ggerganov/llama.cpp cd llama.cpp cmake -B build \ -DGGML_CUDA=ON \ -DCMAKE_CUDA_ARCHITECTURES=70 \ -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release -j $(nproc)这里最关键的是CMAKE_CUDA_ARCHITECTURES=70,对应 V100 的 compute capability 7.0。不写的话,cmake 可能会编译出只适用于当前驱动默认架构的 kernel,结果在 V100 上要么报no kernel image available,要么明明装了 CUDA 却一直在用 CPU 跑。编译完成后,可执行文件在build/bin/llama-server和build/bin/llama-bench。llama-bench后面做基准测试会用到。
还有个小坑:编译前先确认驱动和 CUDA 版本。我用的是 CUDA 12.x 配比较新的驱动,V100 依然在支持名单里。如果你的驱动很老,建议先nvidia-smi看一眼驱动版本,再决定要不要升级。
3.3 GGUF 模型获取:下载还是自己转
最省事的方式是直接下载现成的 GGUF 文件。HuggingFace 上 Qwen 官方仓库就有 GGUF 格式版本,用 huggingface-cli 下载即可。
huggingface-cli download Qwen/Qwen2.5-27B-Instruct-GGUF \ qwen2.5-27b-instruct-q4_k_m.gguf \ --local-dir ./qwen27b如果你手上只有 HF 的 safetensors 原始权重,也可以自己转。llama.cpp 仓库里自带转换脚本,流程是先转 FP16 的 GGUF,再量化成目标格式。
python3 convert_hf_to_gguf.py /path/to/Qwen2.5-27B-Instruct \ --outfile qwen27b-f16.gguf --outtype f16 ./build/bin/llama-quantize qwen27b-f16.gguf qwen27b-q4_k_m.gguf Q4_K_M自己转的好处是可以控制量化动作,坏处是转换脚本和模型格式需要匹配。如果报tokenizer相关错误,多半是模型文件里加了新的特殊 token,而 convert 脚本版本太老,建议更新 llama.cpp 到较新版本再试。
4. 第一次部署:4 tok/s 是怎么来的
4.1 我最初的启动命令和实测表现
第一次启动我很偷懒,直接敲了这么一条命令:
./build/bin/llama-server \ -m ./qwen27b/qwen2.5-27b-instruct-q4_k_m.gguf \ -c 8192 \ --port 8080然后进 API 发了一个问题,生成速度稳定在 3~5 tok/s,首 token 要等 1.5 秒左右,整个体验非常痛苦。当时我下意识觉得“V100 跑 27B 果然不行”,但转头用nvidia-smi一看,显存占用只有 1.2GB,GPU 利用率接近 0%,CPU 倒是吃满了一半核心。这明显说明 GPU 根本没干活。
再看 llama-server 的启动日志,里面有一行非常关键:
load_tensors: offloaded 0/48 layers to GPU0 层在 GPU 上,意味着整个模型都跑在 CPU 内存里。那时候我才反应过来,llama-server 默认不会把任何层 offload 到 GPU,需要显式指定-ngl或者-gpu-layers。CPU 跑一个 17GB 的 Q4_K_M 模型,性能上限基本就是 4~8 tok/s,因为 CPU 内存带宽再高也就几十 GB/s,跟 V100 的 900GB/s 差了一个数量级。
4.2 默认参数为什么这么慢
这里我把慢的原因拆成几个点,方便对照排查:
-ngl没有设置,默认 0,模型全在 CPU。-c 8192把上下文开得很大,KV Cache 默认是 FP16,不仅占显存,还占带宽。注意此时连 GPU 都没用上,这部分全在内存里。- 没有开启 KV Cache 量化,也没开 Flash Attention,潜在优化一个没用。
- 日志里
offloaded 0/48说明 GPU 闲置,但如果你看日志只看开头几行,很容易忽略这个问题。
有人可能会说,那直接把-ngl拉到 999 不就行了吗?在 32GB 的 V100 上大体可以,但在 16GB 的 V100 上,这么干会直接 OOM。所以下一步要先把显存账算清楚,再决定怎么 offload。
5. 调优实录:把参数一个个“压”到 V100 上
5.1 显存预算计算:权重、KV Cache 和 CUDA 上下文
部署大模型最怕的就是显存溢出。我先算了一笔账。模型权重 Q4_K_M 大约 17GB,KV Cache 的大小和上下文长度、模型的层数、KV heads、head_dim 有关。以常用 27B 指令模型为例,单 token 的 KV Cache 在 FP16 格式下大约是 96KB 左右。4096 上下文就是 4096 × 96KB ≈ 400MB。如果 KV Cache 用量化格式 Q8_0,体积减半,大约 200MB。
然后是 prefill 阶段的计算缓冲区和 CUDA context,这部分要留 1~2GB。所以整体算下来:
- Q4_K_M 权重:约 17GB
- 4096 上下文 + Q8_0 KV Cache:约 0.3GB
- CUDA 上下文/计算缓冲:约 1.5GB
- 合计:约 18.8GB
在 V100 32GB 上完全放得下,剩了 13GB 富余。即使把上下文开到 8192,KV Cache 也才 600MB 左右,依然没问题。但如果换成 16GB 的 V100,这个配置就危险了,得走 5.4 节的替代方案。
KV Cache 的显存计算没必要背死数,关键是记住一个公式:KV 越大、上下文越长、精度越高,显存越高。想要在显存有限的情况下跑更长上下文,第一选择是把 KV Cache 量化,第二选择是缩短上下文,而不是去换更大的模型。
5.2 核心参数逐个解释:为什么这样调
我把最终用到的核心参数一个个拆开说,这些参数在 llama.cpp 的最新版本里都有效。
-ngl / --gpu-layers:控制多少层放到 GPU。设999表示全部放 GPU。日志里offloaded 48/48就是全量 offload。这一步是速度从 4 提到 50 的根本原因,GPU 和 CPU 之间每多一层跨设备传输,速度就会掉一截。
--flash-attn on:Flash Attention 可以显著降低解码时 KV Cache 的读取压力。V100 不是 Ampere 新卡,但新版 llama.cpp 已经适配了 Volta 的 FA kernel。开启后显存占用和速度都有改善。如果编译版本不支持,启动时会报错,这时候建议重新编译而不是关掉。
--cache-type-k / --cache-type-v:设置 KV Cache 的数据类型。我用的q8_0,质量影响很小,显存省一半。也有人用q4_0,但我试下来在长上下文任务里偶有质量下降,所以保守选 Q8_0。27B 模型本身对 KV 精度不是特别敏感,但关键任务还是别选太激进。
-c / --ctx-size:上下文长度。我最终用 4096,而不是一开始的 8192。上下文越长,KV Cache 占用越大,解码时每次读取的数据也越多,速度会下降。如果业务对长上下文要求不高,4096 是一个性能和质量比较平衡的值。对于 RAG 场景,如果你只喂一小段文档,那 2048 也够,速度还能再快一点。
--batch-size 和 --ubatch-size:这两个参数影响 prefill 阶段的速度。prefill 就是用户输入的那一段,模型需要一次性并行处理所有输入 token。batch-size 设太小,prefill 慢;设太大,显存爆。V100 上我试了 512 比较稳,256 也能跑但 prefill 稍慢,1024 在某些配置下会 OOM。注意这两个值不是越大越好,要结合显存。
--threads 和 --threads-batch:CPU 线程数。即使 GPU 推理,模型加载、tokenizer、采样等环节也会用到 CPU,设置过多反而会互相抢资源。我按物理核数的一半设了 8,效果比默认值好。
--mlock 和 --no-mmap:把模型文件锁在内存里,避免被 swap 到磁盘。对纯 GPU 推理影响不大,但做部分 offload 时必须开。我实际部署时开了--mlock,没开--no-mmap,因为--no-mmap会一次性把所有文件读进内存再传给 GPU,启动慢且占用更大内存。
--parallel:并发槽位。默认 1,每个并发槽位都会额外分配一份 KV Cache。单卡 V100 做演示,我建议保持 1,否则并发请求会互相拖慢。如果并发需求高,显存又有限,还不如用 Nginx 在 upstream 上做多实例负载均衡。
5.3 最终启动命令和实测数据
最后稳定的启动命令是这样的:
./build/bin/llama-server \ -m ./qwen27b/qwen2.5-27b-instruct-q4_k_m.gguf \ --host 0.0.0.0 \ --port 8080 \ -ngl 999 \ -c 4096 \ --flash-attn on \ --cache-type-k q8_0 \ --cache-type-v q8_0 \ --batch-size 512 \ --ubatch-size 512 \ --threads 8 \ --threads-batch 8 \ --mlock \ --parallel 1启动后日志里能看到offloaded 48/48 layers to GPU,显存占用大约 19GB,GPU 利用率在对话过程中稳定在 80% 以上。我用一个固定问题集测了 100 轮生成,统计结果是这样的:
| 配置阶段 | 上下文长度 | KV Cache 类型 | decode 速度参考 |
|---|---|---|---|
| 初始默认(无 -ngl) | 8192 | FP16 | 4 tok/s |
| 全量 GPU + 8192 + FP16 KV | 8192 | FP16 | 30~35 tok/s |
| 全量 GPU + 4096 + Q8_0 KV + FA | 4096 | Q8_0 | 45~55 tok/s |
| 最终优化 + 短上下文任务 | 2048 | Q8_0 | 峰值 64 tok/s |
测试时别只看聊天页面里的速度估算,最好用llama-bench做统一基准。
./build/bin/llama-bench \ -m ./qwen27b/qwen2.5-27b-instruct-q4_k_m.gguf \ -p 128 -n 128 -r 5它会分别测预填充速度和生成速度,可以输出平均 tok/s,比肉眼估算靠谱。这里提醒一句:长对话生成的后期,KV Cache 越来越大,单 token 要读的 KV 也越来越多,速度会略降。所以标称 64 是短上下文峰值,长对话稳定在 50 左右是很正常的。
5.4 如果只有 16GB 的 V100 怎么办
很多人的 V100 其实是 16GB 版本。这种情况下 Q4_K_M 的 17GB 权重塞不下,我的建议是换 Q3_K_S 量化,文件大约 13GB,全量 offload 后还能留出给 KV Cache 和上下文的显存空间。
./build/bin/llama-server \ -m ./qwen27b/qwen2.5-27b-instruct-q3_k_s.gguf \ -ngl 999 \ -c 2048 \ --flash-attn on \ --cache-type-k q8_0 \ --cache-type-v q8_0 \ --mlock \ --port 8080如果你就是不想牺牲质量,坚持 Q4_K_M,那就只能部分 offload。例如把-ngl设为 40,也就是 48 层里 40 层放 GPU、8 层放 CPU。这样显存占用大概 14GB 多,速度能到 15~25 tok/s。这个数字不惊艳,但比一开始的 4 tok/s 好太多。再往上调-ngl很容易 OOM,需要反复试。
我个人的建议是:16GB V100 上,如果业务要求实时交互,优先 Q3_K_S;如果离线批处理能容忍慢一点,那 Q4_K_M 部分 offload 更划算。
6. 生产接入与常见问题
6.1 常见问题速查表
部署和调优过程中,我踩过的坑不少,整理成一张表方便排查。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 日志显示 offloaded 0/48 | 没加-ngl | 启动命令加-ngl 999 |
| CUDA error: out of memory | 权重或 KV Cache 超显存 | 降低-c、KV 用 Q8_0、换小量化档 |
| 提示系统资源不足、无法完成 API | 显存被其他进程占满或容器配额不够 | nvidia-smi查占用,kill 残留进程;容器检查--gpus配置 |
| 加载模型报 not a valid model | GGUF 文件与二进制版本不匹配 | 升级或回退 llama.cpp 版本 |
| 开启 Flash Attention 报错 | 编译时没有包含对应 CUDA kernel | 用-DCMAKE_CUDA_ARCHITECTURES=70重新编译 |
| 模型在 GPT/对话时乱码 | 采样参数极端或 tokenizer 版本不对 | 恢复默认采样参数,更新模型和代码版本 |
| 显示 GPU 利用率高但速度仍慢 | 部分层还在 CPU,跨设备传输频繁 | 查看 offloaded 层数,尽量全量 offload |
| 访问时报 self_signed_cert_in_chain | 用 HTTPS 访问本地服务,证书校验问题 | 改成http://127.0.0.1:8080或配置证书信任 |
关于“系统资源不足”这类问题,我第一次遇到也很懵,最后发现是另一个 Python 进程占着 14GB 显存没释放。nvidia-smi能看到进程列表,必要时用fuser -v /dev/nvidia*找到占用进程,确认后清理掉再启动服务。
6.2 OpenAI 兼容 API 和 Qwen Code 接入
llama-server 启动后,本身就兼容 OpenAI 的/v1/chat/completions接口,接入成本极低。用 curl 测一下:
curl http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen-27b", "messages": [ {"role": "user", "content": "用一句话介绍你自己"} ], "temperature": 0.7, "max_tokens": 256 }'注意这里model字段随便填,llama-server 不校验,真正起作用的是启动时加载的模型文件。如果用 Python,openai SDK 只需要改base_url:
from openai import OpenAI client = OpenAI( base_url="http://127.0.0.1:8080/v1", api_key="not-needed" ) resp = client.chat.completions.create( model="qwen-27b", messages=[{"role": "user", "content": "你好"}], temperature=0.7 ) print(resp.choices[0].message.content)有些工具在配置本地 API 时会默认填 HTTPS,比如 Qwen Code 或其他客户端,如果它报connection error: self_signed_cert_in_chain,大多数时候不是服务端问题,而是客户端用 HTTPS 访问本地 HTTP 服务,证书校验失败。把地址改成http://127.0.0.1:8080/v1,或者关掉客户端的证书校验,问题就消失。
6.3 还能继续挖的性能空间
这套配置跑稳之后,还可以继续优化。比如 V100 支持多卡,llama.cpp 可以用--split-mode layer或row做多卡并行,两张 V100 拆模型权重,速度还能再往上走。但多卡部署的显存带宽是分摊的,层切分会有跨卡传输开销,不一定线性提升,得自己测。
另一个方向是 speculative decoding,用一个小模型做 draft,大模型做 verify,V100 上如果配合得好,短文本生成可以提高 1.5~2 倍。llama.cpp 的 API 支持--draft参数,但 draft 模型也需要额外显存,要做取舍。
如果应用场景是 RAG,不要盲目追求长上下文。用户问题加检索片段一般也就 1000~2000 token,把上下文控制在这个范围,每一轮生成都快不少。说白了,性能瓶颈不在模型能不能更长,而在你的业务到底需不需要那么长。
最后再分享一个我个人的经验:调优大模型部署,第一件事永远是看显存带宽和显存占用,而不是急着调采样参数和 prompt。先算权重要多少、KV 要多少、CUDA 上下文要多少,再决定量化格式和上下文长度。我一开始就是没算账,结果 4 tok/s 跑了大半天。后来把账算明白,参数一套上,速度直接起飞。这块被很多人嫌弃的 V100,其实还能在存量硬件上发挥很大价值,关键是别让它闲着。