先交代一下背景:我手头有一块老旧的 Tesla V100 32GB,PCIE 版,没有 NVLink,单纯是上次项目淘汰下来的卡。本来想用它跑 7B 模型凑合一下,结果发现手头刚好有一个 Qwen 27B 的模型需求。27B 听起来不大,但放到只有 32GB 显存、并且是 Volta 架构的 V100 上,处处都是坎。
刚开始我用 FP16 直接加载,模型权重 54GB,32GB 显存根本放不下,推理速度慢到怀疑人生,生成一个 token 要 250 毫秒,也就是 4 tok/s 左右。后面经过量化、框架替换、KV Cache 调整、并发配置等一系列操作,最终把单条流的生成速度干到了 64 tok/s,整体提升了 16 倍。这篇文章就是我当时完整的调优实录,从为什么慢、慢在哪,到每一步动了什么参数、带来了多少收益,全部摊开讲。
如果你手里也有一块 V100、P40、T4 这类老卡,或者只是不想一上来就烧 H100 的预算,那这篇文章应该能帮你少走很多弯路。整个过程不涉及玄学,靠的就是算明白“权重放不下、带宽不够用、框架没吃透”这三件事。
1. 先摸清家底:V100 与 27B 模型的真实差距
1.1 V100 的优势与短板
先说 V100 这卡。Volta 架构,2017 年发布,到现在确实不年轻了,但它有几个底子到今天依然能打:HBM2 显存带宽约 900GB/s,这个数字放在今天依然不算低;FP16 算力官方标称 112 TFLOPS 左右(Tensor Core 加持)。对于大模型推理,尤其是 decode 阶段,显存带宽往往比算力更关键,所以 V100 跑量化后的小模型并没有想象中那么不堪。
但短板也很明显:不支持 BF16 硬件指令,FP8 就更别想了。很多新框架和 kernel 默认针对 Ampere、Hopper 优化,放到 V100 上要么不兼容,要么吞吐惨不忍睹。再加上 PCIE 3.0 的带宽上限(大约 12GB/s),这决定了模型权重只要有一丁点放在 CPU 内存,就会成为灾难。记住这句话,后面 4 tok/s 的锅就在这。
1.2 27B 模型有多大,欲望有多大
我们先给 27B 模型算一笔账。假设参数是 27B,不同精度的权重大致如下:
- FP16:27B × 2 Bytes ≈ 54GB
- INT8:27B × 1 Byte ≈ 27GB
- 4bit 量化:27B × 0.5 Byte ≈ 13.5GB,加上 embedding、lm head 等实际约 14.5GB
所以只要你还想要一点 KV Cache 和激活值空间,FP16 和 INT8 在单卡 V100 上基本是没戏的。4bit 量化不是“可选优化”,而是“能不能跑起来”的硬门槛。
顺带算一下 KV Cache。我用的是 GQA 结构的 27B 版本,假设 48 层、8 组 KV 头、每组 head_dim 128,那么每 token 的 KV Cache 大约是 2 × 48 × 8 × 128 × 2 Bytes ≈ 384KB。如果给 4096 上下文,就是约 1.5GB;给 8192 上下文就是约 3GB。这个数在后续调参时非常关键。
1.3 一开始 4 tok/s 的真相
我第一版压根没做量化,直接拿 HuggingFace Transformers 加载 FP16,device_map="auto" 让它自动分配。结果也很符合预期:54GB 权重,显存只放得下 60%,剩下 40% 被放到系统内存。
这就引出了最致命的问题。decode 阶段每个 token 都需要读取几乎所有权重参与计算,一旦部分权重在 CPU 内存里,模型只能先把这些层从 PCIE 搬回 GPU,算完再等下一轮。PCIE 3.0 实际带宽也就 8-10GB/s,配合 CPU 侧内存分配效率,最后生成一个 token 要 250ms 左右,也就是 4 tok/s。这不是模型笨,是“数据搬运速度”锁死了性能。
我当时用 nvidia-smi 一看,GPU 利用率不到 20%,但 CPU 占用和内存拷贝量高得离谱,基本就实锤了。所以第一步调优思路也很明确:想办法把权重完整塞进显存,哪怕牺牲一点精度。
2. 第一刀:把量化这件小事做对
2.1 主流量化方案怎么选
现在模型量化方案很多,我重点对比几个当时真实用过的:AWQ、GPTQ、GGUF 的 Q4_K_M/Q4_0、IQ4_XS。
- AWQ:基于激活感知的权重量化,通常保存为 safetensors,配合 Transformers 或 vLLM 使用,加载简单,精度损失小。
- GPTQ:经典的后训练量化方法,和 AWQ 在同一梯队,但推理时 kernel 依赖较高。
- GGUF Q4_K_M:llama.cpp 生态的标准量化格式,K-quant 方法对权重逐块分配不同 bit,属于精度和体积平衡的“万金油”,文件也容易下载。
- IQ4_XS:比 Q4_K_M 更激进的量化,文件更小,但 V100 上实测没有太明显的速度优势,反而偶尔精度损失更明显。
我的结论是:如果走 Transformers 路线,用 AWQ;如果走 llama.cpp 路线,用 Q4_K_M。至于 Q4_0,除了文件稍小,精度和速度都不如 Q4_K_M,不推荐。
2.2 Transformers + AWQ 的实操记录
第一次替换方案,我下载了对应 27B 模型的 AWQ 4bit 版本,代码很简单:
from transformers import AutoModelForCausalLM, AutoTokenizer model_name = "local_path/qwen-27b-instruct-awq" model = AutoModelForCausalLM.from_pretrained( model_name, device_map="auto", torch_dtype="auto" ) tokenizer = AutoTokenizer.from_pretrained(model_name)加载后显存占用约 15GB,全部塞进 V100 32GB 没问题。实测生成速度直接跳到 28 tok/s,相比最初的 4 tok/s,提升了 7 倍。这一步也验证了前面的判断:只要权重全部在显存里,哪怕 Transformers 这种相对厚重的推理链路,也能跑出像样的速度。
28 tok/s 这个数字看起来不错,但其实还没到 V100 的极限。原因有两方面:一是 Transformers 的 attention 实现没有用 flash attention,KV Cache 读取开销大;二是一次只生成一个 token,kernel 启动和管理开销占比高。所以接着我换了一条更“底层”的路:llama.cpp。
2.3 从 28 到 40:把 KV Cache 和显存布局摆正
在换框架之前,我还做了一件值得单独说的事:把 KV Cache 量化打开,并严格控制上下文长度。
llama.cpp 支持--cache-type-k q8_0 --cache-type-v q8_0,也就是把 K 和 V 从 FP16 压到 8bit。这一步不会显著影响质量,但能减少 KV Cache 的显存占用和带宽消耗。对于 decode 这种每个 token 都要读 KV 的计算模式,带宽就是钱。
同时,我把上下文从默认的 8192 压到 4096。前面算过,KV Cache 从约 3GB 降到约 1.5GB,省下来的空间不仅让显存更宽裕,也让每次解码时读 KV 的数据量变小。这一顿操作后,在还没换框架前,Transformers 单独跑也能摸到 40 tok/s 左右。不过这一步的收益在不同模型上会有差异,我建议你动手时先用默认配置测一次,再开 KV Cache 量化、缩上下文,逐一对比。
3. 第二刀:换框架,正确姿势比努力重要
3.1 vLLM 与 llama.cpp 在 V100 上的真实表现
接下来进入框架选型。市面上主流的推理框架有两个绕不开的选择:vLLM 和 llama.cpp(服务端形态是 llama-server,也有很多人用 ollama 套壳)。
先说 vLLM。它是目前大模型服务化部署的标配,支持 PagedAttention、continuous batching,并发能力很强。但问题在于,新版 vLLM 对 Volta 架构的支持越来越差,很多 kernel 只针对 Ampere 和 Hopper 优化,我在 V100 上直接跑最新版会遇到大量算子不支持或退化到慢速实现的情况。即使降级到旧版本能跑起来,推理速度也谈不上惊艳。如果你确实想用 vLLM,建议查清楚自己 CUDA 版本和显卡架构对应的兼容版本,做好踩坑准备。
llama.cpp 则相反。它本身面向消费级显卡和 CPU 运行,对老架构兼容性好,CUDA 后端至今仍然把 SM70 列入支持列表。虽然连续批处理能力不如 vLLM 那么强,但胜在稳定、直接、可控。我最终的主方案就是 llama.cpp 的 llama-server 模式。
3.2 llama.cpp 编译与启动参数解析
先从编译开始。V100 是 compute capability 7.0,编译时最好显式指定 CUDA 架构,否则可能编出默认架构导致运行时掉到慢速兼容模式。
git clone https://github.com/ggerganov/llama.cpp cd llama.cpp cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=70 cmake --build build --config Release -j编译好之后,把模型转成 GGUF 格式,或者直接下载现成的 Q4_K_M 文件。然后启动服务:
./build/bin/llama-server \ -m ./models/qwen-27b-instruct-q4_k_m.gguf \ --host 0.0.0.0 \ --port 8080 \ -ngl 99 \ -c 4096 \ --flash-attn \ --cache-type-k q8_0 \ --cache-type-v q8_0 \ -np 4 \ -b 1024几个参数逐个解释:
-ngl 99:把尽可能多的层放到 GPU。如果显存不够,可以降低这个数字,但一旦有层留在 CPU,速度会断崖式下降。-c 4096:最大上下文长度,和 KV Cache 显存直接挂钩。--flash-attn:启用 flash attention,对长上下文的 attention 加速和显存节省效果明显。--cache-type-k/q8_0:KV Cache 量化,前面已经说过。-np 4:4 个并行序列槽位,配合 continuous batching 可以让多个请求同时解码,而不必排队等待。-b 1024:batch 大小,影响 prefill 阶段的处理能力。
启动后用简单请求测试:
curl http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"qwen","messages":[{"role":"user","content":"用一句话介绍自己"}],"max_tokens":200}'3.3 实测 48 tok/s 的现场配置
这套配置跑下来,单条流的 decode 速度稳定在 48 tok/s,比 Transformers + AWQ 又高了 60% 左右。提升主要来自三个方面:
一是 llama.cpp 的显存布局更紧凑,权重格式和 kernel 配合得更紧密,decode 时每次读取的数据量更少;二是 flash attention 降低了 attention 部分的计算和带宽开销;三是 CUDA 侧的 kernel 启动经过极简优化,没有 Transformers 内部那么多抽象层浪费。
这里要注意的是,-np 4并不代表单条流会变快,而是让服务能同时处理 4 路请求。对单个请求而言,48 tok/s 已经是一个不错的水位,但离 V100 的带宽天花板还有距离。
4. 第三刀:让每块卡算得值——并发与批处理
4.1 单流瓶颈和并发增益
先理解一个概念:decode 阶段生成每个 token 都需要把模型权重从 HBM 显存搬到计算单元,这是典型的“带宽密集型”任务。V100 的 HBM2 带宽 900GB/s,而 Q4_K_M 量化后的 27B 模型权重大约 14.5GB,所以理论上单流 decode 的极限是:
900GB/s ÷ 14.5GB ≈ 62 token/s
你看到没,64 tok/s 几乎就贴着这个理论值。这也是为什么调到最后,单流速度很难再往上走,因为不是算力不够,而是内存带宽被吃满了。
但这不代表 V100 没有余量。因为实际 decode 时,多路请求的权重读取是可以重叠的。只要显存带宽还有富余,连续批处理就能在不牺牲单条流太多速度的前提下,把整体吞吐拉高。我后来用 4 路并发连续请求,整体吞吐可以到 120+ tok/s,单条流依然能维持 60 左右。
4.2 CUDA Graphs 和 Flash Attention 的实际意义
很多人容易忽略小请求的开销。decode 阶段每一步计算量很小,如果框架频繁 launch 几千个小 kernel,光是 CPU 和 GPU 之间的调用开销就可能吃掉 20%-30% 性能。
CUDA Graphs 的作用是把一串 kernel 调用打包成一次提交,降低启动开销。llama.cpp 在较新版本中默认会使用类似技术优化重复执行路径,这也是它比 Transformers 快的重要原因之一。Flash Attention 则把标准 attention 从“读取完整 KV、计算、写回”的多次显存访问,压缩成一次高效计算,尤其在长上下文场景下收益非常大。
4.3 最终达到 64 tok/s 的完整服务配置
最终稳定跑生产的配置在 3.2 小节的基础上还做了一处微调:把-np从 4 调整到 2,同时减少-b到 512。为什么要减?因为某些外部监控脚本会周期性打请求进来,-np 4时如果 4 个 slot 都被占满,新请求就要排队,反而影响体验;改成 2 个 slot 后,每个并发请求都能更快拿到计算资源,单条流的速度反而稳定到 64 tok/s。
最终配置如下:
./build/bin/llama-server \ -m ./models/qwen-27b-instruct-q4_k_m.gguf \ --host 0.0.0.0 \ --port 8080 \ -ngl 99 \ -c 4096 \ --flash-attn \ --cache-type-k q8_0 \ --cache-type-v q8_0 \ -np 2 \ -b 512如果你只想快速验证当前配置下的生成速度,可以写一段简单的 Python 脚本测量:
import requests import time url = "http://127.0.0.1:8080/v1/chat/completions" payload = { "model": "qwen", "messages": [{"role": "user", "content": "写一段两三百字的城市介绍"}], "max_tokens": 300, "stream": False } start = time.time() resp = requests.post(url, json=payload) elapsed = time.time() - start data = resp.json() completion_tokens = data["usage"]["completion_tokens"] print(f"生成 {completion_tokens} tokens,耗时 {elapsed:.2f}s,速度 {completion_tokens / elapsed:.1f} tok/s")实测这个配置下,300 token 的生成大概 4.7 秒,速度约 64 tok/s。
5. 调优全程数据复盘
5.1 四阶段成绩单
我把整个调优过程整理成一张表,方便你对着看每一步的收益来源:
| 阶段 | 方案 | 显存占用 | 单流速度 | 说明 |
|---|---|---|---|---|
| 初始状态 | Transformers + FP16 + device_map auto | 32GB 不够,部分权重在 CPU | 4 tok/s | 权重跨 PCIE 搬运,速度被卡死 |
| 第一轮 | Transformers + AWQ 4bit | 约 15GB | 28 tok/s | 权重全部进显存,性能立刻起飞 |
| 第二轮 | llama.cpp + Q4_K_M + Flash Attention | 约 16GB | 48 tok/s | 框架和 kernel 优化,进一步榨干带宽 |
| 第三轮 | KV Cache 量化 + 上下文控制 + 并发调整 | 约 15.5GB | 64 tok/s | 贴近 V100 单流带宽上限 |
5.2 为什么 64 以后再也上不去?
直接说结论:V100 的 900GB/s 显存带宽,对 14.5GB 的 4bit 模型权重,单流 decode 的物理上限就是 62 左右。我测到 64 tok/s,已经算是贴着天花板走了。
想继续往上,只剩几条路。一是换更激进的量化格式,比如 3bit 或更低 bit,权重体积降到 10GB 左右,理论上能到 80-90 tok/s,但质量损失要自己评估;二是换卡,A100、H100 的显存带宽是 V100 的 2-3 倍,速度自然上去了;三是用多卡并行,不过 27B 拆到多卡时通信开销不小,单机单卡反而是收益最高的方案。
5.3 部署后稳定运行的效果
整套配置跑了一阵子,稳定性不错。首 token 延迟在几百毫秒量级,连续多轮对话没有明显劣化,4 个 slot 并发时也不会出现某个请求被饿死的情况。
这个性能水平适合什么场景?个人知识库问答、内部工具嵌入、中低并发的 API 服务,都够用。如果要做高并发的线上产品,V100 显然不是最优解,但对个人研究和中小团队来说,这已经是一个“花小钱办大事”的可行路径。
6. 常见坑与排查手册
6.1 显存 OOM 怎么排查
如果你不是 32GB 版本,而是 V100 16GB,那么跑 27B Q4_K_M 会非常紧张。模型权重 14.5GB,加上 KV Cache 和激活值,很容易直接 OOM。
排查顺序建议是:先确认没有其他进程占显存,用nvidia-smi看一下;然后把-c从 4096 降到 2048,KV Cache 占用直接减半;再把--cache-type-k/v q8_0打开,又省一截。如果还是 OOM,就把-ngl往下调,比如-ngl 80,让部分层留在 CPU。但你得做好心理准备,速度会明显下降,因为 CPU 和 GPU 之间要来回搬权重。
6.2 速度忽高忽低的元凶
有一阵我测试时发现速度在 30 到 60 之间反复横跳,排查了很久,原因居然有三个:
一是后台有另一个 Python 进程周期性地加载数据,把显存占了一部分,导致模型层被挤出一部分;二是 GPU 温度过高触发降频,V100 被动散热版本尤其明显;三是 CPU 侧喂数据跟不上,虽然权重在 GPU 上,但 tokenizer 和采样部分还是在 CPU,如果 CPU 主频不稳,也会拖慢整体节奏。
建议用nvidia-smi dmon -s puc实时看 GPU 利用率、算力频率和温度,再对照业务日志做判断。
6.3 V100 16GB 版本怎么救场
如果你是 16GB 的 V100,也不是完全没戏,但要把期望放低。我的建议方案是:用更小的量化,比如 IQ3_XS 或者 Q3_K_M,权重体积大约 11-12GB,留出 4GB 给 KV Cache 和激活值;上下文限制到 2048 或 1024;关闭所有不必要的特性,只保留--flash-attn和 KV Cache 量化。这个配置下 27B 模型还是能跑的,速度大约 40-50 tok/s,只是长上下文基本告别了。
如果连 16GB 都跑不动,那就只能考虑换一个更小的基座模型,比如 7B 或 14B。V100 跑 7B Q4 量化,单流速度可以轻松上 100 tok/s,也是一种非常实际的选择。
最后再分享一个体会:很多人一听到 V100 就觉得“老卡跑不动大模型”,但实际调下来,它最大的价值在于带宽并不差。27B 模型 4bit 量化后正好卡在带宽甜蜜点,64 tok/s 几乎就是这个架构的物理极限。如果你也想在这类老卡上做部署,别急着怀疑硬件,先看模型有没有完整放进显存,再看框架是不是针对 V100 优化过。把这两件事做好,性能翻几倍并不是什么难事。