多卡消费级机器跑 DeepSeek 这类大模型,很多人第一反应是"没有 NVLink,没救了"。实际情况往往不是这样:真正拖慢速度的,是没配对的内核、走错路径的通信,以及不合适的批处理参数。把这三件事理顺,同一套硬件常常能跑出明显更高的吞吐。
这篇能做出什么
按下面的步骤走完,会得到三样东西:
- 一张拓扑与带宽的实测底数表,能说清楚你的多卡机器到底是卡在 PCIe 带宽、P2P 支持,还是显存容量上;
- 一份针对消费级架构(Ampere / Ada 等)补齐注意力与量化内核的运行时配置,让 DeepSeek 的权重能用上适合的内核而不是退回通用实现;
- 一份可复现的吞吐测试脚本,能对比调优前后的 tokens/s 与延迟,而不是靠"感觉快了一点"。
调优幅度取决于卡型、PCIe 通道数、主板拓扑与所选模型规格,不做承诺,但方向是确定的:先量化瓶颈,再改配置。
前置条件清单
- 一台装了 2 张及以上同型号消费级显卡的机器,Ubuntu 类 Linux 系统;
- 已装好与内核匹配的 NVIDIA 驱动,
nvidia-smi能正常列出所有卡; - 已安装 CUDA Toolkit(含
nvcc)或至少能编译 CUDA 扩展; - Python 3.10+ 与一个干净的虚拟环境;
- 一个推理框架(vLLM、SGLang、TensorRT-LLM 等,以官方文档当前版本为准)与一份可用的 DeepSeek 权重(官方全量版对消费级机器偏重,蒸馏版或社区量化版更适合起步);
- 主板 BIOS 里已开启 Above 4G Decoding 与 Resizable BAR(若支持)。
先确认驱动与拓扑:
```bash
nvidia-smi
nvidia-smi topo -m
```
nvidia-smi topo -m 输出的矩阵里,NV# 表示走 NVLink,PIX / PXB / PHB 表示不同的 PCIe 路径,SYS 表示要绕到 CPU 侧的 PCIe 主机桥再过去。消费级平台大量是 PHB 和 SYS,这个矩阵基本决定了通信上限。
第 0 步:把瓶颈测出来,别猜
先看链路宽度和速率有没有跑满:
```bash
nvidia-smi --query-gpu=index,name,pcie.link.gen.current,pcie.link.width.current \
--format=csv
lspci -vv | grep -i -E "LnkCap|LnkSta" | head -40
```
LnkSta 明显低于 LnkCap(比如能力是 x16 实际只协商到 x4),说明卡插槽或转接线有问题,先把这一步解决。
再测真实的多卡通信带宽。编译 nccl-tests(以官方仓库当前版本为准)后:
```bash
2 卡 all-reduce 带宽
./build/all_reduce_perf -b 8M -e 512M -f 2 -g 2
```
同时用 cuda-samples 里的 p2pBandwidthLatencyTest 看 P2P 是否启用:
```bash
./p2pBandwidthLatencyTest
```
如果输出的 P2P 带宽和"经主机内存中转"的带宽差不多,说明 P2P 实际没生效,通信走的是 pinned memory 中转。这是消费卡上很常见的状态。
顺带检查 PCIe ACS(Access Control Services),它会把同一条 PCIe 交换机下的设备通信强制绕行:
```bash
sudo lspci -vvv | grep -i "ACSCap\|ACSctl"
```
如果确实因为 ACS 导致 P2P 被拦,可以在内核命令行加 pci=noacs(改 /etc/default/grub 的 GRUB_CMDLINE_LINUX,update-grub 后重启)。这条改动影响整机 PCIe 行为,生产机器上请先评估。
第 1 步:内核补齐——别让模型跑在通用实现上
"内核补齐"指的是:让注意力、量化矩阵乘这些热点算子,用到为你的 GPU 架构专门编译的实现,而不是框架里的兜底路径。
1.1 注意力后端
主流框架通常内置多种注意力后端。消费级常见架构(如 sm_86、sm_89)不一定在预编译轮子里,需要自己按架构编译:
```bash
export FLASH_ATTN_CUDA_ARCHS="80;86;89" # 换成你 GPU 的算力值
export TORCH_CUDA_ARCH_LIST="8.0;8.6;8.9"
pip install --no-build-isolation .
```
具体后端名称与环境变量随框架版本变化,以官方文档当前版本为准。要点只有一个:编译时的架构列表必须覆盖你的卡,否则会静默回退。
1.2 量化与低精度 GEMM
DeepSeek 系列权重常用 FP8 或 INT4 量化格式发布。FP8 的硬件支持与架构强相关,较老的消费卡往往需要反量化到 BF16 或走 INT4 内核。
如果用的是 INT4/AWQ/GPTQ 权重,优先启用 Marlin 类内核(框架里通常是 --quantization marlin_awq、--quantization gptq_marlin 这类选项)。这类内核在 Ampere 与 Ada 上做了针对性优化,比通用反量化路径快不少。
验证内核是否真的加载,最直接的办法是把日志级别调高,启动时搜索后端名称:
```bash
VLLM_LOGGING_LEVEL=DEBUG vllm serve <your-model> --tensor-parallel-size 2 2>&1 | tee boot.log
grep -i -E "attention|marlin|fp8|kernel" boot.log | head -30
```
如果日志里出现 fallback、emulate 之类的词,说明这条路径没吃到硬件加速,需要换后端或换量化格式。
1.3 CUDA Graph
解码阶段每次前向的算子很小,启动开销占比高。开启 AI 词典:CUDA Graph">CUDA Graph 捕获(框架里通常是默认开启,或需要显式 --enforce-eager 的反面配置)能削掉一部分调度开销。它对显存有额外占用,显存紧张时再关。
第 2 步:通信重构——减少 PCIe 上的往返
没有 NVLink 时,张量并行(TP)每层都要做 all-reduce,通信量大且对延迟敏感;流水并行(PP)只在层边界传激活,通信量小得多。所以在 PCIe 机器上,一个实用的策略是:减少 TP 规模,用 PP 或更大的单卡负载来换更少的集合通信。
先给 NCCL 一组保守的、适合纯 PCIe 环境的配置:
```bash
export NCCL_IB_DISABLE=1 # 没有 InfiniBand 就别让它去找
export NCCL_SHM_DISABLE=0 # 保留共享内存通道
export NCCL_P2P_DISABLE=0 # 先允许 P2P,测过再决定
export NCCL_DEBUG=WARN
export NCCL_ALGO=Ring # PCIe 拓扑上 Ring 通常比 Tree 稳
export NCCL_PROTO=Simple
export NCCL_SOCKET_IFNAME=lo # 单机多卡,避开慢网卡干扰
export NCCL_MAX_NCHANNELS=4 # 通道过多会互相抢 PCIe 带宽
```
改完用 nccl-tests 复测一遍 all-reduce 带宽,再跑一遍推理吞吐。关键的对照实验是这一组:
```bash
A:允许 P2P
NCCL_P2P_DISABLE=0 ./your_bench.sh
B:禁用 P2P,强制走共享内存 + 主机中转
NCCL_P2P_DISABLE=1 ./your_bench.sh
```
如果 B 反而更快,说明你的 P2P 路径是"半残"状态:能连上但带宽低、延迟高,还拖累了调度。这时果断禁用 P2P 更划算。反过来,如果 A 明显快,就把 P2P 保留并优先解决 ACS、插槽分配这类底层问题。
另外两件值得做的事:
- 把卡插在直连 CPU 的插槽上,避免两张卡挂在同一个 PCIe 交换机后面互相争带宽;
- TP 规模尽量取 2 或 4,而不是无脑等于卡数。TP=1(单卡放得下)时通信开销直接归零,宁可降低量化精度或缩短上下文。
第 3 步:批处理与显存参数
通信优化完,剩下的收益大多来自"把显存用满、把 batch 做大"。以 vLLM 为例,参数名与默认值以官方文档当前版本为准:
```bash
vllm serve <your-model> \
--served-model-name deepseek-local \
--tensor-parallel-size 2 \
--max-model-len 8192 \
--gpu-memory-utilization 0.92 \
--max-num-seqs 64 \
--max-num-batched-tokens 4096 \
--enable-chunked-prefill \
--enable-prefix-caching
```
几个调参的直觉:
--gpu-memory-utilization从 0.85 逐步往上抬,直到启动不报 OOM。消费卡显存有限,每 0.02 都可能多塞几十个 KV cache 槽位。--max-num-seqs决定并发解码的序列数。它太小,吞吐上不去;太大,单请求延迟被拉长,且长上下文场景容易 OOM。--max-num-batched-tokens控制单次前向塞多少个 token。chunked prefill 打开后,长 prompt 会被切片,避免一个超长请求把整批解码卡住。--enable-prefix-caching对系统提示词固定、多轮对话的场景收益明显,因为公共前缀只算一次。- KV cache 数据类型若框架支持降到 FP8,可以直接把可用槽位翻倍,代价是极小的精度损失,用前在业务数据上验一遍。
第 4 步:吞吐测试,跑出可复现的数字
不要用"体感"评估。下面这个脚本用 OpenAI 兼容接口,固定 prompt 与输出长度,扫描不同并发,输出吞吐与平均延迟:
```python
bench_throughput.py
import asyncio, statistics, time
import aiohttp
URL = "http://127.0.0.1:8000/v1/completions"
MODEL = "deepseek-local" # 与 --served-model-name 一致
OUT_TOKENS = 128
ROUNDS = 2
PROMPT = "请用中文说明分布式系统中一致性、可用性与分区容忍之间的关系。" * 20
async def one(session, payload):
t0 = time.perf_counter()
async with session.post(URL, json=payload) as r:
data = await r.json()
return time.perf_counter() - t0, data["usage"]["completion_tokens"]
async def run(session, concurrency):
sem = asyncio.Semaphore(concurrency)
async def worker():
async with sem:
return await one(session, {
"model": MODEL,
"prompt": PROMPT,
"max_tokens": OUT_TOKENS,
"temperature": 0,
"ignore_eos": True, # 保证输出长度固定,便于对比
})
t0 = time.perf_counter()
results = await asyncio.gather(*[worker() for _ in range(concurrency * ROUNDS)])
elapsed = time.perf_counter() - t0
out_tokens = sum(t for _, t in results)
print(f"并发={concurrency:>3} 输出吞吐={out_tokens / elapsed:8.1f} tok/s "
f"平均延迟={statistics.mean(d for d, _ in results):6.2f} s")
async def main():
timeout = aiohttp.ClientTimeout(total=1800)
async with aiohttp.ClientSession(timeout=timeout) as session:
for c in (1, 4, 8, 16, 32):
await run(session, c)
asyncio.run(main())
```
ignore_eos 是 vLLM 的扩展字段,其他框架若不支持可去掉,但要接受输出长度浮动带来的对比误差。
跑测试的规矩:
1. 每次只改一个变量,先记下基线;
2. 每个配置至少跑两轮,取稳定值;
3. 同时关注吞吐与单请求延迟——吞吐涨了但延迟翻倍,对交互式场景未必是好事;
4. 长上下文(比如 32K)单独测一轮,短 prompt 的成绩不能代表它。
常见坑与排错
启动就 OOM,但 nvidia-smi 显示显存还空着。 通常是 KV cache 预分配过大。先降 --max-model-len,再降 --gpu-memory-utilization,最后才动 TP 规模。
多卡比单卡还慢。 几乎都是通信问题。按第 0 步重测 all-reduce 带宽,做一次 NCCL_P2P_DISABLE 的 A/B 对照。如果 P2P 是半残状态,禁用它反而更快。
调高 --max-num-seqs 后吞吐不涨。 说明瓶颈不在批大小,而在算力或显存带宽。此时应该回头看内核是否真的加载了专用实现,以及模型是不是跑在 FP8 仿真路径上。
nvidia-smi topo -m 显示 SYS。 意味着通信要绕到 CPU 侧的主机桥,延迟天然高于 PHB。这种拓扑下,减少集合通信次数(降低 TP、用 PP)比调 NCCL 参数更有效。
开启 P2P 后偶发卡死或数值异常。 部分消费卡组合上的 P2P 稳定性不佳。除非有明确收益,否则在需要长期稳定运行的服务上,禁用 P2P 是更省心的选择。
改了内核后精度变差。 低精度 GEMM 内核在极端输入下可能有数值差异。用相同的 prompt 集对比输出,确认在业务可接受范围内再上线。
BIOS 改动后无法启动。 记录改动前的原始值,改一项测一项,别一次改多个选项。
下一步建议
把上面流程跑通后,可以往三个方向继续:
一是把测试脚本接到 CI 或定时任务里,每次改配置或升级框架自动跑一轮,用数据决定回滚还是保留。硬件不变的情况下,一份历史吞吐曲线比任何参数调优经验都可靠。
二是做预填充与解码分离。长 prompt 的预填充是算力密集型,解码是访存密集型,两者混在一批里会互相拖累。部分框架已经支持把这两段拆到不同实例上,在 PCIe 机器上收益比较直观。
三是评估权重量化格式。从 BF16 换到 FP8、INT4,显存占用和带宽压力都会下降,代价是精度。用一套固定的评测问题集对比,而不是只看困惑度数字。
最后提醒一句:消费级显卡的调优空间主要来自"把已有能力用对",而不是榨出硬件不具备的性能。每一步都先测量、再改配置、再复测,这套节奏本身比任何一个具体参数值都重要。
