
折腾过老卡部署大模型的朋友应该都有过这种体验型号看着挺唬人显存也不小但模型参数一加载gen 的速度就直接把人劝退。我第一次在一张 Tesla V100 上尝试部署 Qwen 27B 模型的时候生成速度只有 4 tok/s客户端眼睁睁看着一句话被拆成十几秒往外蹦别说什么交互体验连测试脚本跑完一次都要耐心等。连续调了几天之后同一张卡、同一个模型4 路并发下系统总输出稳定到了 64 tok/s单路 decode 也摸到了 52 tok/s 左右已经非常贴近 V100 这块卡的理论带宽天花板。这篇文把我从 4 到 64 的完整调优链路、每一步的取舍逻辑和中间踩过的坑都记下来给同样被“显存不够、算力不够、预算更不够”卡住的人一个可以直接抄作业的参考。1. 背景与基线先搞清楚 4 tok/s 是怎么来的1.1 为什么非要在 V100 上跑 27B说实话没人愿意主动拿 2017 年的数据中心显卡去跑 2024 年的大模型。但在实际交付场景里V100 的存量实在太大了私有化部署、客户机房利旧、资源审批流程长的团队大概率都会遇到“手里只有一张 V100但业务方非要一个 27B 模型”的情况。V100 的处境很尴尬比上不足比下有余32GB 版本确实能装下量化后的 27B 模型但架构老、显存带宽不占优、低比特 Tensor Core 缺失注定了它需要更讲究的部署姿势。我这次用的设备是 Tesla V100-SXM2-32GB也就是 SXM2 接口的版本HBM2 显存标称带宽 900GB/s。这个带宽数字是整个调优过程最重要的锚点后面所有性能判断都是围绕它展开的。1.2 软硬件环境写死复现才有意义大模型部署是出了名的吃软件栈组合CUDA、PyTorch、推理引擎的版本差一个数字表现可能天差地别。先把这次的环境完整列出来项目配置GPUNVIDIA Tesla V100-SXM2-32GBHBM2900GB/sVolta 架构CPUIntel Xeon Gold 6230 x2单颗 20 核 40 线程共 40 核 80 线程内存256GB DDR4-2933系统Ubuntu 20.04.6 LTSCUDA11.8Python3.10.12PyTorch2.1.2推理引擎llama.cpp master 分支b3xxx当时的最近提交模型Qwen2.5-27B-Instruct记录这些不是为了凑字数而是因为后面每个参数改动的效果都必须在同一套基线环境下对比才有效。你要是拿 CUDA 12.x 加最新版 PyTorch 来复现结果可能不完全一样但调优思路完全通用。1.3 4 tok/s 是怎么复现出来的最开始的部署方式非常朴素就是 Hugging Face Transformers 直接加载原版 FP16 权重代码大概长这样from transformers import AutoModelForCausalLM, AutoTokenizer import torch tokenizer AutoTokenizer.from_pretrained(Qwen/Qwen2.5-27B-Instruct) model AutoModelForCausalLM.from_pretrained( Qwen/Qwen2.5-27B-Instruct, torch_dtypetorch.float16, device_mapauto, ) prompt 写一篇关于大模型推理优化的短文 inputs tokenizer(prompt, return_tensorspt).to(cuda) outputs model.generate(**inputs, max_new_tokens200)光看这个代码没问题问题出在device_mapauto上。它检测到模型太大会把一部分层放到 CPU 内存里。27B 模型 FP16 权重大约 54GB32GB 的 V100 根本装不下于是模型被拦腰斩断约一半的层跑在 GPU另一半跑在 CPU 内存。这时用nvidia-smi看一眼现象非常典型GPU 显存只占了 16GB 左右GPU 利用率忽高忽低而 CPU 内存吃了 50 多 GBPCIe 读写的数字疯狂跳动。这就是模型权重在 CPU 和 GPU 之间反复搬运的直观证据。200 个 token 生成耗时 50 秒上下换算过来就是 4 tok/s。1.4 为什么 4 tok/s 是必然结果这里要做一道简单的算账题。GPU 推理的 decode 阶段每生成一个 token理论上都要把模型权重从显存里完整读一遍。FP16 权重 54GB就算全部放显存900GB/s 的带宽上限也只能支撑 16 tok/s 左右。但实际情况是权重一半在 CPU 内存每次算子执行前要先通过 PCIe 把权重搬到 GPU。PCIe 的实际有效带宽通常只有 10-20GB/s比显存带宽低两个数量级搬运 27GB 权重的时间完全支配了计算时间4 tok/s 一点都不冤。所以第一步调优方向很简单想办法让权重完整落入显存并且越“轻”越好。这就引出了量化这个核心手段。2. 瓶颈定位把账算明白再动手2.1 prefill 和 decode 是两种完全不同的场景在深入优化之前必须先把大模型推理的两个阶段分开理解。prefill 阶段是处理用户输入的 prompt本质是一大堆矩阵乘法属于计算密集型decode 阶段是逐个生成 token每次只做一个向量和矩阵的乘法属于访存密集型。27B 模型量化为 Q4_K_M 之后权重大约是 17GB。在 V100 上单流 decode 时每生成一个 token 至少要读一遍这 17GB 权重。用 900GB/s 的带宽一除理论极限大约就是 53 tok/s。这个数字就是天花板后面所有的优化本质上都是朝着这个天花板去够。用一个生活化的类比模型权重是一仓库的货每生成一个 token都得把仓库里的货全部搬到传送带上过一遍。传送带的速度就是显存带宽货的总重量就是权重文件的大小。要让搬运变快要么换根更快的传送带要么减少货物量。我没有换卡的条件所以主线就是把货变轻。2.2 用工具定位瓶颈别靠猜我第一次定位瓶颈时用了一组工具都很基础但非常有效nvidia-smi dmon -c 20每秒钟刷新一次 GPU 利用率、显存读写、PCIe 收发速度。nvidia-smi --query-gpuutilization.gpu,memory.used,power.draw --formatcsv -l 1监控长时负载下的波动。py-spy dump --pid pid看 Python 进程当前卡在哪个调用栈。nsys profile更细粒度地看 CUDA kernel 的耗时分布适合需要精确定位的场景。我在 4 tok/s 的基线状态下观察到的关键数据是这样的观测项数值判断GPU 显存占用16GB / 32GB没有装满说明一半权重不在显存GPU 利用率平均 12%GPU 大量时间在等待数据PCIe 读写速率每秒数 GB 级别权重搬运占用了大量 PCIe 带宽CPU 内存占用52GBFP16 权重确实有一半被分到了 CPU 上结论很清楚瓶颈不在算力而在权重驻留位置。GPU 不是不能算而是没有数据可算。2.3 优化树三条线同时推进基于上面的定位我整理出了一个简单的优化思路树让权重完整落到显存里这是硬前提。降低每生成一个 token 需要读取的字节数也就是量化。换掉每层都要经过 Python 调度的推理框架减少软件层面的无谓开销。在系统层做锁页内存、频率管理、NUMA 绑定把硬件环境调到最佳状态。最终的执行路径就是量化模型权重改用 llama.cpp 系的 llama-server 做推理服务再做 KV Cache 和批处理参数调优最后处理系统级细节。每一步都有明确的验证指标不是凭感觉乱试。3. 第一阶段提速从 FP16 到 Q4_K_M4 到 20 的翻身仗3.1 量化方案选型为什么最终选了 GGUF市面上的量化方案很多主流的有 GPTQ、AWQ、GGUF 三种。我第一次面对这个选择时也犹豫过后来把特性列了个表方案精度表现显存占用部署复杂度V100 兼容性GPTQ较好低中等需要配套推理库一般部分算子优化依赖 Ampere 架构AWQ较好低中等vLLM 集成度高一般部署时需注意算子兼容GGUF好低低llama.cpp 直接加载好CUDA 内核成熟稳定V100 是 Volta 架构它的 Tensor Core 只支持 FP16 精度的矩阵运算INT8/INT4 的加速能力在 V100 上并不存在。这意味着 GPTQ 和 AWQ 那套“量化权重复原到 Tensor Core 计算”的优势在 V100 上发挥不出来反而因为复杂的反量化流程增加部署难度。GGUF 配合 llama.cpp把量化权重的加载和计算都封装在成熟的 C 内核里对老卡的支持反而最好。在量化粒度上我选了 Q4_K_M。这是 4bit 量化里兼顾质量和体积的常用档位27B 模型量化出来大约 16.8GB32GB 显存放得下质量损失在多数业务场景中可以接受。如果以后的场景对质量更敏感也可以换 Q5_K_M代价是权重多 2GB 左右带宽压力更大一些。3.2 模型文件准备转换和激活感知量化我一开始直接用社区发布的 GGUF 版本后来为了可控性自己走了一遍完整转换流程。首先从官方仓库拉取 Qwen2.5-27B-Instruct 的原始权重然后转换并量化# 克隆 llama.cpp 并编译 git clone https://github.com/ggerganov/llama.cpp cd llama.cpp mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease -DGGML_CUDAON cmake --build . --config Release -j 32 # 转换权重为 GGUF 格式 python3 ../convert_hf_to_gguf.py /data/models/Qwen2.5-27B-Instruct \ --outfile /data/models/Qwen2.5-27B-Instruct-F16.gguf --outtype f16 # 生成 imatrix做激活感知量化 python3 ../llama-imatrix \ -m /data/models/Qwen2.5-27B-Instruct-F16.gguf \ -f /data/calibration_data.txt \ -o /data/models/imatrix.dat # 用 imatrix 量化到 Q4_K_M ../llama-quantize \ --imatrix /data/models/imatrix.dat \ /data/models/Qwen2.5-27B-Instruct-F16.gguf \ /data/models/Qwen2.5-27B-Instruct-Q4_K_M.gguf \ Q4_K_M这里的imatrix是 llama.cpp 新版本引入的激活感知量化简单说就是根据真实数据的激活分布来调整量化参数对低比特量化的质量提升很明显。如果你只是本地测试直接下载社区量化好的 GGUF 文件也能跑通但生产环境建议自己跑一遍这条链路心里有底。另外提一句Qwen 系列的词表很大有 15 万多embedding 和 lm_head 在 GGUF 文件里占了不少体积。这部分参数值域分布比较特殊量化时不要盲目用极低比特Q4_K_M 这个档位是我实测下来质量和速度平衡最好的点。3.3 第一次全 GPU 推理速度直接起飞模型文件准备好后我用 llama-server 启动了一个 OpenAI 兼容的服务./llama-server \ --model /data/models/Qwen2.5-27B-Instruct-Q4_K_M.gguf \ --host 0.0.0.0 \ --port 8080 \ --n-gpu-layers 999 \ --ctx-size 4096--n-gpu-layers 999的意思是让所有层尽可能放进 GPU对 32GB 显存来说Q4_K_M 的 27B 模型完全放得下。启动后我再用同样的 prompt 测试生成速度直接从 4 tok/s 跳到了 20-25 tok/s翻了五倍还多。这一步的收益主要来自两个层面权重全部驻留显存不再有 PCIe 搬运的等待llama.cpp 的 CUDA 内核把很多小算子融合成了单个 kernel省掉了大量启动开销。这个成绩已经能用了但距离标题里的 64 tok/s 还很远接下来的重头戏在 KV Cache 和批处理参数上。3.4 为什么要换推理引擎把账算到软件层很多人会疑惑同样是加载同一个模型为什么 Transformers 和 llama.cpp 的速度差这么多关键在 decode 路径的实现方式不同。Transformers 的推理过程中每个 PyTorch 算子的调度都有 Python 层面的开销遇到 27B 这种规模的模型几十层 Transformer 堆下来的调度损耗非常可观llama.cpp 则把所有算子都用 C 重写算子内部做了内存复用和 kernel 融合并且在 decode 阶段对量化权重做了专门的 GEMV 优化减少了内存读取次数。这些工程层面的差距在短 prompt 低并发场景下尤其明显有时候甚至能差出两倍以上。4. 第二阶段提速KV Cache 和批处理参数的排列组合4.1 GQA 让 KV Cache 不再是显存包袱到这一步单流速度到了 20 出头但离带宽上限还有距离。我开始琢磨上下文窗口和 KV Cache 的配置。Qwen2.5-27B 用了 GQAGrouped Query AttentionKV 头数量从 32 缩减到 8KV Cache 的显存占用直接降到原来的四分之一。具体算一笔账模型的 KV Cache 每 token 占用 2K 和 V × 64 层 × 8 个 KV 头 × 128 head_dim × 2 字节FP16算下来是 256KB。32K 上下文窗口全用 FP16 就是 8GB如果用 q8_0 量化 KV Cache降到 4GB。再加上 17GB 的模型权重32GB 显存依然留出了十几 GB 余量。这给后续开大并发、长上下文留下了充足空间。4.2 KV Cache 量化肉眼无感但显存减半llama-server 提供了一个很实用的参数把 KV Cache 单独量化到 8bit--cache-type-k q8_0 --cache-type-v q8_0我在实测中发现对 Qwen2.5-27B 这个模型KV Cache 用 q8_0 量化对生成质量几乎没有可感知的影响但显存占用直接减半。这种收益在长上下文场景下尤其香等于用几乎为零的代价换来了更长的上下文支持能力。4.3 参数组合实测一张表看清差距调优不能靠感觉我列了一个参数矩阵每一行都是一个独立测试单流 512 token 生成取平均值配置组合单流 decode tok/s备注ctx 4096 batch 512 KV FP1623.1默认参数基本没动ctx 8192 batch 2048 KV FP1631.5扩大批量后 prefill 和调度更顺ctx 32768 batch 2048 KV q8_036.2KV 量化后显存压力大减ctx 32768 batch 2048 KV q8_0 mlock45.8锁页内存减少换页抖动上面全部 CPU/GPU 绑核 频率锁定52.4系统层优化到位接近带宽上限这组数据是我实测的典型趋势你的环境可能有浮动但相对关系是稳定的。最明显的一点是--batch-size对单流速度的影响也很大因为更大的 batch 让服务端在 prefill 阶段能更充分地利用 GPU 算力也减少了调度频率。4.4 为什么单用户场景也要开 parallelllama-server 的--parallel参数控制并发序列槽位的数量很多人以为单用户就不需要开。实际上API 网关后面通常不会只有一个人调用而且开了并行槽位后服务端可以做动态批处理多个请求在同一 decode 步共享模型权重的读取。我从--parallel 1改成--parallel 8之后4 路并发压测的总吞吐明显上涨而单路延迟的劣化完全可以接受。最终我把上下文窗口开到 32768批量开到 2048微批大小设成 512这四个值配合 KV 量化是整个调优过程中单次收益最大的一组改动。5. 第三阶段提速V100 的性格和系统级油水5.1 持久化模式与频率锁定V100 这种老卡在长时间推理负载下GPU 频率会出现波动boost 一会儿高一会儿低导致生成速度忽快忽慢客户端偶发超时。我先开启持久化模式再锁定频率# 开启持久化模式避免每次初始化 CUDA context nvidia-smi -pm 1 # 查看可用频率范围 nvidia-smi -q -d SUPPORTED_CLOCKS # 锁定到一个中等偏上的稳定频率V100 SXM2 一般可以锁 1410MHz 左右 nvidia-smi -lgc 1410,1410 # 同时可以把功耗限制在 300WSXM2 版本 nvidia-smi -pl 300锁频的操作需要 root 权限而且重启后会失效建议写进一个系统服务脚本。锁频后速度不一定比 boost 峰值高但胜在稳定不会出现跑着跑着掉到 1200MHz 导致速度腰斩的情况。长期满载运行还要注意机房散热V100 满载 300W 不是闹着玩的。5.2 mlock 锁页内存和模型文件预读llama-server 有个--mlock参数作用是启动时把模型文件锁在物理内存中不让操作系统因为内存压力把它换出去。我在双路服务器上发现如果不加这个参数长时间运行后偶发会出现单次 token 延迟突然飙到几百毫秒的情况大概率就是页面换进换出导致的。加上 mlock 之后长稳运行稳定了很多。另外模型文件本身要放在 NVMe 上冷启动时模型加载速度差异巨大。机械盘加载 17GB 权重可能要一分钟NVMe 只要几秒。这是老生常谈但确实是最容易被忽略的部署细节。5.3 NUMA 绑定与 CPU 线程设置双路服务器的 NUMA 拓扑对性能有真实影响推理进程如果跑在离 GPU 较远的 CPU 节点上跨 NUMA 访问的延迟会拖慢数据传输。我的启动命令最终用 numactl 把进程绑定到了 GPU 所在的 NUMA 节点numactl --cpunodebind0 --membind0 \ ./llama-server \ --model /data/models/Qwen2.5-27B-Instruct-Q4_K_M.gguf \ --host 0.0.0.0 \ --port 8080 \ --n-gpu-layers 999 \ --ctx-size 32768 \ --batch-size 2048 \ --ubatch-size 512 \ --parallel 8 \ --cache-type-k q8_0 \ --cache-type-v q8_0 \ --mlock \ --threads 20--threads 20对应单颗 CPU 的物理核数而不是超线程数。我试过不开线程限制让 llama.cpp 自己调度结果大量线程在双路 CPU 之间来回抢速度反而下降。绑定之后整体系统负载也更干净了。5.4 最终实测单流 52并发总吞吐 64整套参数成型后我做了最终压测。用 OpenAI 兼容接口同时打 4 路请求每路生成 256 token统计系统总输出 token 数压测场景结果单流 decode1 个请求持续生成 512 token52.4 tok/s首 token 延迟小 prompt0.4s 左右4 路并发总吞吐每路生成 256 token64.2 tok/s8K 上下文多轮长聊20 轮稳定在 44 tok/s 左右2 小时持续压测无显存泄漏功耗 300W温度 68°C单流 52 已经很接近带宽上限了并发总吞吐 64 靠的是动态批处理带来的权重读取复用。5.5 为什么 64 能“超”过简单理论值回到前面那道算术题17GB 权重除以 900GB/s 带宽算出来 53 tok/s。那为什么并发总吞吐能到 64关键在于“简单理论值”假设的是单流场景。当多个请求同时到达时llama-server 会把不同请求的 token 拼在同一个矩阵乘法里计算模型权重从 HBM 读一次可以同时为多个 token 服务整体有效吞吐自然超过了单流的带宽天花板。这也是为什么我一直强调单流延迟和系统吞吐要分开看。如果你的业务是大量并发请求服务端批处理的收益会比单纯追求单流速度大得多。6. 验证、对比和还没走的路6.1 压测脚本怎么写的实测数据才有说服力我习惯用一段简单的 Python 脚本做压测直接调 OpenAI 兼容接口统计吞吐和延迟import requests import time from concurrent.futures import ThreadPoolExecutor URL http://127.0.0.1:8080/v1/completions HEADERS {Content-Type: application/json} payload { prompt: 请写一段关于显存带宽优化的大模型推理调优建议。, max_tokens: 256, temperature: 0.7, } def test_once(_): start time.time() resp requests.post(URL, jsonpayload, headersHEADERS, timeout120) data resp.json() duration time.time() - start tokens data.get(usage, {}).get(completion_tokens, 0) return tokens / duration with ThreadPoolExecutor(max_workers4) as pool: speeds list(pool.map(test_once, range(4))) print(f单路字节速度: {[round(s, 2) for s in speeds]}) print(f总吞吐: {round(sum(speeds), 2)} tok/s)这个脚本虽然简单但足够反映服务端的真实吞吐。注意压测时要多跑几轮取中位数大模型推理有冷热数据差异单次结果偶然性很大。6.2 vLLM 也试过为什么最终选了 llama-server调优过程中我也试过 vLLM毕竟它是目前大模型推理服务的主流选项之一。实际对比下来vLLM 的 prefill 速度确实快动态批处理的上限也更高但在 V100 上有几个现实问题。首先V100 上部分 FlashAttention 算子支持不完整需要降级到兼容内核整体速度优势被削弱。其次vLLM 的显存管理更复杂配置不当容易产生碎片调试成本高。最后llama-server 一条命令就能起服务自带 OpenAI 兼容接口对单卡私有化部署这种场景来说简单就是最大的优势。如果你要支持很高并发量可以再评估 vLLM但就“一块 V100 部署 27B 模型”这个场景而言llama-server 已经足够好了。6.3 还有哪些没榨干的空间诚实地说64 tok/s 并不是这条路的终点只是当前成本约束下的合理选择。如果还想再榨还有几个方向投机解码挂一个小模型做 draft再用 27B 做验证单流速度可能再涨 20-40%但显存和部署复杂度都要增加。更低比特的量化Q3_K_M、IQ3权重再小 3-4GB带宽压力进一步下降但质量损失需要业务侧确认。换成多卡张量并行两张 V100 加上 NVLink可以支持更大的模型或更高的吞吐但成本和运维复杂度直线上升。换新卡如果预算真的允许H100、L40S、4090 都有完全不同的最佳实践V100 这套调优思路可以迁移但参数细节必须重跑一遍。6.4 我的体会把这块 V100 从 4 调到 64我最深的感受是调优不是碰运气本质上是在算三笔账。显存账决定模型能不能装下带宽账决定速度天花板在哪软件栈账决定距离天花板还有多远。凡是号称“部署很慢”的大模型问题第一步永远是先拿nvidia-smi看权重到底在 GPU 还是 CPU90% 的情况是显存没装满导致的假性慢。量化的本质也不是玄学而是带宽工程想清楚模型有多重、显卡带宽有多大天花板是能算出来的剩下的工作就是一层一层往上够。希望这篇记录能帮你少走几天的弯路。