1. 一个从吃灰服务器上挖出的结论:老Xeon配V100能扛住现代大模型
如果你手头有一台2016年采购的双路Xeon服务器,平时只能拿来跑点老业务,一块V100 32GB显卡插在上面已经快忘了它的存在,那你大概和我一样,默认这种机器是跑不动现代大模型的。我的实测结论完全相反:Qwen Flash next 部署在这台机器上,生成速度稳定在 20 tok/s,响应节奏已经能作为内部工具被正常使用。
为什么这个数字值得说?因为很多人对老硬件的认知还停留在"显存够但算力不够"的层面。实际上,大模型推理是带宽敏感型负载:权重从显存搬到计算单元的速度,往往比计算本身更关键。V100 的显存带宽放在今天也只是落后,不是不能用;配合合理的框架和量化策略,它就是一台完全可用的本地推理机。
这篇文章适合三类人:一是公司或高校机房还有一批 V100、老Xeon 没被淘汰,想低成本开个内部模型服务的人;二是手头预算有限、想用旧机器搭个离线模型环境的人;三是对推理性能和硬件瓶颈有兴趣,想搞清楚 tok/s 到底是受什么制约的人。我会把我实际的部署路径、测速方法和踩过的坑完整写出来,你可以照着复现,也可以只读原理部分作为参考。
1.1 为什么老设备反而适合做私有化模型部署
先泼一点冷水:这台机器跑不了最新旗舰模型的全量精度,也没必要跑。私有化部署的诉求通常是"数据不出内网、可自主调用、延迟可以接受",至于吞吐量能不能和云端比,并不重要。一个 20 tok/s 的本地模型,配合合适的工程封装,足够胜任文档摘要、命名实体抽取、代码注释生成、客服话术辅助这类任务。
更重要的是,这种机器在不少单位里不是稀缺资源,而是已经折旧完毕、没人争抢的闲置资源。把它们利用起来,不需要新采购审批,不需要机房扩容,成本上几乎是零。之前很多人放弃老设备,不是因为不能用,而是因为新框架默认忽略老卡,给了一点编译报错就劝退了。这篇文章要解决的,正是"怎么让老平台不被软件生态抛弃"这件事。
1.2 我是怎么验证这个结论的
我不会只丢一个"20 tok/s"的结论。后面的内容会按这样的顺序展开:先说清楚这台机器的具体配置和软件环境,包括模型格式转换这种容易卡住的细节;然后展示第一次测速得到的原始数据;再用显存带宽公式解释 20 tok/s 背后的物理意义;接着讲几个在老卡上特有的坑;最后给出我从 20 调到 28 那两次关键调整的具体做法和最终参数。
整套过程中,我坚持的原则是:每改一个变量只动一个东西,测速前固定好 prompt 和上下文长度。这样得到的数据才有对比价值,而不是靠感觉说"变快了"。
2. 测试环境的完整清单:Xeon、V100、驱动和模型格式转换
2.1 服务器硬件与系统版本
我用的是一台典型的 2016 年双路服务器,具体配置如下表。这里值得注意的其实不是"型号多老",而是"接口和带宽还够不够"。
| 部件 | 配置 | 备注 |
|---|---|---|
| CPU | 双路Xeon E5-2690 v4,每颗14核28线程 | 合计28核56线程,主频2.6GHz |
| 内存 | 128GB DDR4-2400 | 8条16GB,内存通道填满 |
| GPU | V100 32GB,SXM2接口 | 算力7.0,NVLink互联 |
| 系统 | Ubuntu 22.04 LTS | 内核5.15 |
| 驱动 | 基于CUDA 11.8的驱动 | 这个版本和7.0算力兼容性最好 |
| 存储 | 系统盘NVMe SSD,模型放本地SSD | 模型加载耗时约2分钟 |
我特别提一下 SXM2 接口。V100 有 PCIe 版和 SXM2 版,SXM2 版的显存带宽更高(约 900GB/s),功耗上限也更高。如果你手里是 PCIe 版,同样模型很可能只有 12~15 tok/s,这是接口和显存频率的差异,不是软件问题。所以看到别人报的 tok/s 时,先问一句用的是哪个版本,否则对比没有意义。
2.2 模型准备:先把BF16切成FP16
这步是绝大部分第一次在老卡上跑新模型的人都会卡住的地方。现在新发布的大语言模型,不少直接给 BF16 权重,而 V100 的 Tensor Core 只支持 FP16,不支持 BF16。如果你直接把 BF16 权重丢给推理框架,轻则警告,重则直接报 dtype 不匹配。
我的处理方式是先做一次精度转换:把模型的 BF16 权重读取进来,转换成 FP16 存盘。具体操作用一个能加载模型的 Python 脚本,遍历权重表,把 dtype 为 torch.bfloat16 的张量转成 torch.float16,然后保存。核心代码大概长这样:
import torch state = torch.load("model_pytorch.bin", map_location="cpu") for key, value in state.items(): if value.dtype == torch.bfloat16: state[key] = value.float().half() torch.save(state, "model_fp16.bin")转换后的模型体积基本不变,因为 BF16 和 FP16 都是 2 字节。转换完之后,我一定要验证一下指定关键词的输出,避免转换过程出现张量损坏。实测转换后生成质量和 BF16 几乎没有可感知差异,整体损失可以忽略。
2.3 推理框架的选型与第一次启动
对老卡来说,框架选择直接影响能否跑起来。我第一轮用的是社区里很流行的本地部署引擎,部署简单,依赖少,对老 GPU 兼容性好;第二轮换成面向高吞吐的服务化引擎做对比,结论后面会展开。
首轮启动命令大概是这样的:
./llama-server \ -m /models/qwen-flash-next-fp16.gguf \ --n-gpu-layers 99 \ -c 4096 \ --host 0.0.0.0 --port 8080这里-m指向转好的 FP16 GGUF 文件,--n-gpu-layers 99表示把所有层都放进显存。第一次启动会做 CUDA kernel 编译和模型加载,耗时大概两分钟。启动成功后,我习惯先用一行命令测最小请求:
curl http://127.0.0.1:8080/v1/completions \ -H "Content-Type: application/json" \ -d '{"prompt":"请用一句话介绍自己","max_tokens":64}'返回正常时,这个老平台就算跑通了。这一步的意义在于建立"可用基线",后续所有优化都围绕这个基线做对比。多说一句,--n-gpu-layers 99这种写法是故意把层数设满的,GPU 显存放得下就全部丢给 GPU,CPU 只做调度和采样,避免权重在 CPU 和 GPU 之间反复搬运。
3. 20 tok/s的真实含义:从带宽测算到性能归因
3.1 一次规范的测速流程
"20 tok/s"这种数字,脱离测法就没有意义。我自己用的测法很固定:单并发、流式输出;固定一个 600 字左右的输入 prompt,让模型不间断生成 200 个 token,统计从第一个 token 到最后一个 token 的平均间隔。输入处理的时间单独记录,不计入 decode 速度。
下面是我记录的一组典型数据(使用 FP16 权重,上下文长度 4096,未做量化):
| 指标 | 数值 |
|---|---|
| 输入长度 | 768 tokens |
| 首token延迟 | 约0.8秒 |
| decode速度 | 20.1 tok/s |
| 端到端生成200 token耗时 | 约10.5秒 |
| 显存占用 | 约28.5GB |
你看,生成 200 个 token 约 10.5 秒,平均每秒输出 20 个左右。这个速度下,人读起来有轻微的"等字出现"的感觉,但完全跟得上阅读节奏,用于内部工具是合格的。
3.2 用显存带宽反推速度上限
我特别喜欢用带宽公式来校验实测数据,这能快速判断一个数字是正常还是异常。大模型 decode 时,每一步都要把全部权重从显存搬运到 SM,计算量本身往往不是瓶颈,带宽才是。粗略公式是:
理论上限 ≈ 显存带宽 / 每token需要搬运的显存数据量这台 V100 的显存带宽约 900GB/s。FP16 模型加载进显存后,权重和 KV cache 合计占 28.5GB 左右,那么每生成一个 token,权重相关搬运至少是 28.5GB 的量级。900 除以 28.5,理论极限其实只有约 31 tok/s。我们实测 20,说明已经跑到理论极限的 65% 左右,余量不大了。
为什么达不到 100%?因为还有 kernel 启动开销、注意力计算、CPU 和 GPU 的数据交换、以及显存读写本身并非纯顺序。65% 的利用率放在老卡上,已经是相当健康的水平。
我把这段写出来,是想告诉你一个容易被忽略的事实:在这类显存带宽不高不低的卡上,模型越小越容易跑得快,参数越大越吃带宽。这也是为什么量化往往比换更快的卡更划算。
3.3 decode与prefill为什么要分开看
老机器上还有一个典型现象:输入处理比 decode 慢一个数量级。因为输入处理是密集计算,非常吃 GPU 的 FP16 算力,而 V100 的 Tensor Core 性能在今天的标准下确实不够看了。输入 768 token 跑一次输入处理耗时 0.8 秒,均摊到每个输入 token 约 1ms,这个数字放在新一代显卡上可以被虐哭,但在老平台上是正常的。
实际操作中,我会针对输入处理做两件事。第一,把输入 prompt 压缩改写,能用 300 字表达清楚就不要用 1000 字;第二,尽量复用缓存,相同前缀对话不要重复处理。这两点对交互体感的影响,有时比优化 decode 速度更明显。
4. 老卡上的几个隐蔽坑:编译兼容、线程干扰与显存溢出
4.1 算力7.0的软件兼容边界
V100 的算力是 7.0,而当前主流推理引擎为了新卡的算子优化,经常会逐步放弃对 7.0 的编译支持。我踩到的第一个坑就是:用最新版本框架编 CUDA kernel 时,编译阶段直接提示架构不支持。
解决办法是锁定旧版本或加编译参数。旧版本虽然功能少一些,但对 7.0 的支持完备,稳定性更好。这提醒我们,老设备上"追新"的风险很高,版本选型的第一原则是兼容性,性能优化要在兼容基础上再做。
遇到报错时,我建议先搜错误信息中的sm_70或compute capability,而不是直接换框架版本。能兼容 7.0 的版本列表通常很明确,选定一个稳定版本后,把它作为这台服务器的固定依赖,轻易不要动。
4.2 双路CPU的物理距离与线程调度
双路 Xeon 看起来有 56 个线程,似乎很充裕,但在跑推理时,线程调度不当会导致性能波动。NUMA(非一致性内存访问)结构下,GPU 插在 PCIe 的某颗 CPU 侧,模型数据和请求线程如果跨 CPU 访问内存,时延会升高,生成速度会不稳。
我最初直接把线程数设成 56,结果反而比设成 28 更慢。原因是调度器把负载分散到两颗 CPU 上,跨 die 访问内存和 PCIe 链路,开销比多线程带来的并发收益更大。改成将推理进程和 GPU 绑定在同一颗 CPU 的 14 个物理核上之后,速度反而稳定在 20 以上。
如果你也遇到"CPU 占用不高但速度上不去"的情况,先查一下进程落在哪颗 CPU 上,再决定要不要加numactl --cpunodebind之类的绑定参数。这条经验在双路服务器上非常常见,单路机器反而不会遇到。
4.3 显存用量为什么比估算高一截
很多教程都会告诉你"模型多少B就占多少GB显存",但实测总是偏高。这个现象在老卡上尤其明显。V100 32GB 跑 FP16 模型,理论权重只有不到 20GB,但加载后总量跑到了 28.5GB,接近上限。
原因有三层:KV cache 是按最大上下文预留的;CUDA context 和算子临时缓存会额外占用;碎片化导致显存无法完全复用。解决思路不是算精确值,而是留出 20% 冗余。
如果显存不够但有第二块 V100,还可以把模型切到多卡。多卡时用 NVLink 互联,吞吐损失不大,但如果中间隔了 PCIe 交换机,速度会掉一截,建议先看拓扑再决定。
4.4 千万别忽略的OOM表现
显存溢出在老卡上表现得特别隐蔽。有时候不是直接报"Out of memory",而是生成到一半速度骤降,甚至卡住几秒后输出乱码。我排查过一次,最后发现是显存刚好卡在临界点,框架开始偷偷把部分层回退到 CPU 计算,速度自然崩了。
我的经验是:显存占用超过总容量 90% 时,就要当心。要么缩短上下文长度,要么换量化权重,要么降低并发数。非要卡着临界点跑,等于埋了一颗定时炸弹。
5. 把速度再往上顶:从20到28的两次有效调整
5.1 第一刀:压缩权重并保住精度
从 FP16 改成 4bit 量化后,模型权重从 28.5GB 降到 8GB 以内,显存带宽公式里的"每token搬运量"大幅下降,理论上限直接翻倍。我用了基于校准集的 4bit 量化方式,生成质量在摘要类任务上几乎没有损失。
量化后的实测速度到了 27~28 tok/s,接近 FP16 时的 1.4 倍。这也验证了我前面的判断:瓶颈在权重搬运,压低权重体积是最有效的加速手段。要注意的是,量化格式对老卡很有讲究,V100 没有现代卡上针对低精度计算专门强化的路径,所以别盲目选最小的 bit 数,4bit 是一个平衡点,再低的版本在 V100 上反而不稳定。
5.2 第二刀:绑定CPU与GPU的协作位置
量化带来提速后,我又做了一次进程绑定调整,配合量化版本最终稳定到 28。做法是把服务进程和整条数据通路都放在同一 NUMA 节点上,并给 CPU 设置明确的线程数,而不是让框架自动探测 56 个线程。
实际启动脚本大概长这样:
numactl --cpunodebind=0 --membind=0 \ ./llama-server \ -m /models/qwen-flash-next-q4_k.gguf \ -t 14 \ --n-gpu-layers 99 \ -c 4096在双路机器上,这个调整带来的提升通常在 5%~10%,但更重要的是稳定性,峰值抖动明显减少,连续请求时速度不再忽高忽下。
5.3 用于日常服务的最终启动参数
最终我保留的是下面这套参数组合,作为这台服务器的日常服务配置:
| 参数项 | 设定值 | 原因 |
|---|---|---|
| 权重格式 | 4bit(Q4_K) | 显存占用与质量平衡 |
| 线程数 | 14 | 单颗物理核数量,避免跨NUMA |
| GPU层数 | 99 | 全部层进显存 |
| 上下文长度 | 4096 | 够内部工具使用,显存可控 |
| 并行槽位 | 2 | 控制显存和CPU开销 |
这套配置下,稳定速度约 28 tok/s,显存占用约 7.5GB,剩余空间足够放两路并发请求。对一台 2016 年的服务器来说,这个结果已经很能打了。
提示:如果你的应用是"一次输入很长、生成很短"的类型,不要只看 decode 速度,重点优化输入处理和缓存命中;如果是"你来我往"的聊天型,decode 速度的意义更大。
5.4 顺带验证过的并发表现
我还测了量化后多并发的情况。2 并发时,单路速度大约降到 22 tok/s,但总吞吐提升到 44 tok/s 左右,显存仍然稳在 10GB 以内。这说明只要权重体积下来了,老卡的并发能力也随之释放。如果几位同事同时用,可以把并行槽位调到 3~4,依然比单路 FP16 的整体体验好不少。
6. 尾声:老设备适合哪种角色
折腾完这台老服务器,我个人的体会是:硬件迭代在 LLM 推理场景里没有想象中那么绝对,瓶颈解析和工程适配往往比直接换卡更值钱。老Xeon 加 V100 这套组合,最终可以稳定承担内部辅助工具、批量文本分析、隔离网络环境模型服务这类角色,作为生产力工具完全合格。
我最后再分享一个小技巧:如果你的服务器上还有其他业务在跑,就把推理服务放到一个独占的 NUMA 节点上,并用环境变量限制 GPU 可见性,避免别的进程把显存挤爆。实际做法是设置CUDA_VISIBLE_DEVICES只暴露目标卡,再配合numactl固定内存节点。这种"给模型留一条独立通道"的思路,比反复调参更能保证长期稳定。
如果你手里也有一台吃灰的老服务器,不妨找个适合部署的模型试一次,也许结果会和预期完全不一样。