四卡 RTX 4090 D 推理服务器:模型部署规划、调试方法论与压测实战
拿到一台四卡机器,四颗 RTX 4090 D(各 24GB),AMD EPYC 9554 双路 224 线程、1TB 内存。上面已经跑着三四个推理服务,但部署是“哪个模型塞哪张卡”式的手工排布,没有整体规划。这篇文章就是把这台机器从头摸一遍——拓扑、显存、部署策略、调试方法、压测——整理成一套可复用的四卡部署方法论。
运行环境:4× NVIDIA RTX 4090 D(24GB / 卡,96GB 合计)· AMD EPYC 9554 / 224 线程 / 1TB 内存 · Ubuntu 24.04.2 · CUDA 12.0 / Driver 570.211.01 · PCIe Gen4(无 NVLink)。
一、先摸清家底:硬件拓扑与 GPU 分配现状
Section titled “一、先摸清家底:硬件拓扑与 GPU 分配现状”1.1 GPU 拓扑实测——没有 NVLink,全靠 PCIe
Section titled “1.1 GPU 拓扑实测——没有 NVLink,全靠 PCIe”nvidia-smi topo -m 的输出比产品手册更诚实:
GPU0 GPU1 GPU2 GPU3GPU0 X PIX PXB PXBGPU1 PIX X PXB PXBGPU2 PXB PXB X PXBGPU3 PXB PXB PXB X解读:
| 连接类型 | 含义 | 本机情况 |
|---|---|---|
| PIX | 至多跨越 1 个 PCIe 桥 | GPU0 ↔ GPU1 |
| PXB | 跨越多 个 PCIe 桥(不跨 Host Bridge) | 其余所有组合 |
| NV# | NVLink 连接 | 无 |
关键发现:这台机器没有 NVLink。 四张卡之间的通信全部走 PCIe。这对张量并行(Tensor Parallelism)的策略有直接影响——跨卡 AllReduce 必须走 PCIe,带宽远低于 NVLink(NVLink 4.0 可达 600 GB/s,PCIe Gen4 x16 双向约 64 GB/s,差了一个数量级)。
GPU0 和 GPU1 在同一个 PCIe Bridge 下(PIX),通信延迟最低;其余组合需要跨桥(PXB)。如果要做 TP=2 的部署,优先用 GPU0+GPU1 这对。
1.2 GPU 显存现状——三张卡几乎打满
Section titled “1.2 GPU 显存现状——三张卡几乎打满”实测各卡显存占用(nvidia-smi):
| GPU | 总显存 | 已用 | 空闲 | 部署的模型 | 用户 |
|---|---|---|---|---|---|
| 0 | 24564 MB | ~10360 MB | ~13731 MB | Embedding/Rerank + TimesFM + Gallery(三服务共享) | sysadmin/aiadm |
| 1 | 24564 MB | ~21612 MB | ~2480 MB | Qwen2.5-VL-7B-Instruct(vLLM 0.10.1) | aiadm |
| 2 | 24564 MB | ~23402 MB | ~690 MB | Qwen3.5-9B w8a8(vLLM) | root |
| 3 | 24564 MB | ~22504 MB | ~1588 MB | Qwen3Guard-Gen-4B fp8(vLLM) | root |
一句话:GPU 1/2/3 基本满了,GPU 0 还有 ~14GB 余量。 后续加模型要么用 GPU 0,要么重新洗牌。
1.3 CPU 与内存——极度充裕
Section titled “1.3 CPU 与内存——极度充裕”- CPU:AMD EPYC 9554,双路 56 核 / 112 物理核 / 224 线程
- 内存:1TB(已用 78GB,剩余 929GB 可用)
- 磁盘:系统盘 437GB(71%),数据盘 /data 3.6TB(26%)
CPU 和内存远不是瓶颈。推理服务的瓶颈在 GPU 显存和算力,CPU/内存可以放心地当作“无限资源”来规划。
二、部署规划:四张卡怎么切
Section titled “二、部署规划:四张卡怎么切”2.1 核心约束:显存是硬天花板
Section titled “2.1 核心约束:显存是硬天花板”24GB / 卡是物理限制。模型加载到 GPU 后,显存占用大致分三块:
总显存 = 模型权重 + KV Cache + 运行开销(CUDA context / 临时张量)- 模型权重:固定开销,加载即占用。量化能减。
- KV Cache:随并发和上下文长度线性增长。vLLM 的
gpu-memory-utilization控制预分配比例。 - 运行开销:CUDA context ~300MB,vLLM 调度器、通信缓冲等 ~500MB-1GB。
2.2 单卡 vs 跨卡:TP=1 优先,无 NVLink 不建议 TP≥2
Section titled “2.2 单卡 vs 跨卡:TP=1 优先,无 NVLink 不建议 TP≥2”张量并行(TP)把模型切片分到多卡,每次前向传播都需要跨卡 AllReduce。本机没有 NVLink,跨卡通信走 PCIe,延迟高:
| 方案 | 适用场景 | 本机评价 |
|---|---|---|
| TP=1(单卡部署) | 模型放得下单卡 | ✅ 首选,无通信开销 |
| TP=2(双卡张量并行) | 模型放不下单卡 | ⚠️ 可用但效率打折,选 GPU0+1(PIX 最近) |
| TP=4(四卡张量并行) | 超大模型(70B+) | ❌ 不推荐,PXB 通信开销吞掉并行收益 |
结论:这台机器的最佳策略是“一卡一模型”(TP=1),用显存利用率(gpu-memory-utilization)精调每个模型的 KV Cache 空间。 只在模型放不下单卡时才考虑 TP=2,且必须用 GPU0+GPU1。
2.3 模型选型:什么放得进 24GB
Section titled “2.3 模型选型:什么放得进 24GB”按显存从大到小排列常见推理模型的单卡需求:
| 模型 | 参数量 | 精度 | 权重大小 | 推荐量化 | 量化后大小 | 单卡可行性 |
|---|---|---|---|---|---|---|
| Qwen2.5-72B | 72B | FP16 | ~145GB | AWQ/INT4 | ~38GB | ❌ 单卡放不下,TP=2 也勉强 |
| Llama 3-70B | 70B | FP16 | ~140GB | AWQ/INT4 | ~36GB | ❌ 同上 |
| Qwen2.5-32B | 32B | FP16 | ~64GB | AWQ/INT4 | ~18GB | ✅ 刚好 |
| Qwen2.5-14B | 14B | FP16 | ~28GB | W8A8 | ~14GB | ✅ 余量充足 |
| Qwen3.5-9B | 9B | FP16 | ~18GB | W8A8 | ~9GB | ✅ 余量大(已部署) |
| Qwen2.5-VL-7B | 7B | FP16 | ~14GB | — | ~14GB | ✅ 已部署 |
| Qwen3Guard-4B | 4B | FP16 | ~8GB | FP8 | ~4GB | ✅ 已部署 |
本机的甜蜜区间:7B-14B 模型单卡部署,或 32B 模型 INT4 量化后单卡。
2.4 推荐部署方案:四卡分工
Section titled “2.4 推荐部署方案:四卡分工”基于现有服务和未来扩展,推荐如下分配:
┌─────────┬────────────────────────────┬────────────┬───────────┐│ GPU 0 │ 轻量服务专区 │ ~10GB 已用 │ +14GB 可扩 ││ │ Embedding / Rerank / TimesFM │ │ │├─────────┼────────────────────────────┼────────────┼───────────┤│ GPU 1 │ 视觉语言模型 (VLM) │ ~21GB 已用 │ 专卡专用 ││ │ Qwen2.5-VL-7B-Instruct │ │ │├─────────┼────────────────────────────┼────────────┼───────────┤│ GPU 2 │ 主力 LLM │ ~23GB 已用 │ 专卡专用 ││ │ Qwen3.5-9B w8a8 (80并发) │ │ │├─────────┼────────────────────────────┼────────────┼───────────┤│ GPU 3 │ 安全护栏 / 小模型 │ ~22GB 已用 │ 专卡专用 ││ │ Qwen3Guard-4B fp8 │ │ │└─────────┴────────────────────────────┴────────────┴───────────┘扩展规划:
GPU 0 的 14GB 余量可以增加:
- 一个 7B INT4 模型(~4-5GB 权重 + KV Cache),作为对话/摘要模型
- 或一个 Reranker 模型(已有 BGE-Reranker 在跑)
- 或一个 TTS / ASR 模型(已有 TimesFM)
2.5 vLLM 启动参数调优清单
Section titled “2.5 vLLM 启动参数调优清单”当前部署的启动参数和优化建议:
# 主力 LLM (GPU 2) — Qwen3.5-9B w8a8vllm serve /bigmodel/Qwen3.5-9B_w8a8 \ --host 0.0.0.0 --port 16000 \ --served-model-name qwen3.5 \ --max-model-len 4096 \ # 当前值,如需长上下文可调到 8192(会吃更多 KV Cache) --gpu-memory-utilization 0.95 \ # 已经很激进,预留 ~1.2GB 给系统 --max-num-seqs 64 \ # 并发序列数,压测后按实际调 --max-num-batched-tokens 8192 \ # 每批 token 数,影响 prefill 吞吐 --enable-chunked-prefill \ # ✅ 已开,长请求不阻塞 decode --enable-prefix-caching \ # ✅ 已开,重复 prompt 命中缓存 --trust-remote-code
# 安全护栏 (GPU 3) — Qwen3Guard-4Bvllm serve /bigmodel/Qwen3Guard-Gen-4B \ --host 0.0.0.0 --port 16001 \ --served-model-name qwenGuard \ --gpu-memory-utilization 0.90 \ # 0.90 而非 0.95,Guard 模型需要更多临时缓冲 --max-num-seqs 256 \ # 高并发,Guard 通常短输入短输出 --enforce-eager \ # 禁用 CUDA Graph,省显存换延迟 --enable-chunked-prefill \ --enable-prefix-caching \ --quantization fp8 \ --trust-remote-code
# VLM (GPU 1) — Qwen2.5-VL-7B(aiadm 用户)vllm serve /data/aiadm/learn-stack/models/Qwen2.5-VL-7B-Instruct \ --host 127.0.0.1 --port 45334 \ --gpu-memory-utilization 0.90 \ --max-model-len 16384 # VLM 通常需要更长的上下文(图片 token 占位多)关键参数决策表:
| 参数 | 作用 | 调大影响 | 调小影响 | 建议值 |
|---|---|---|---|---|
gpu-memory-utilization |
KV Cache 预分配比例 | 更多并发空间,但可能 OOM | 并发少,但稳定 | 0.90-0.95 |
max-model-len |
最大上下文长度 | KV Cache 需求线性增长 | 截断长输入 | LLM 4096-8192, VLM 16384 |
max-num-seqs |
最大并发序列数 | 内存占用增加 | 吞吐受限 | 压测后定 |
max-num-batched-tokens |
每批最大 token 数 | prefill 吞吐高,显存波动大 | prefill 慢 | 8192(默认) |
enforce-eager |
禁用 CUDA Graph | 省显存,延迟增加 | 延迟低,吃显存 | 小模型开,大模型关 |
三、调试方法论:从“不工作”到“为什么”
Section titled “三、调试方法论:从“不工作”到“为什么””部署推理模型最常遇到的四类问题:模型加载失败、显存溢出(OOM)、性能不达标、输出质量异常。这里整理一套系统性的调试流程。
3.1 调试工具箱
Section titled “3.1 调试工具箱”| 工具 | 用途 | 命令 |
|---|---|---|
nvidia-smi |
GPU 状态、显存、进程 | nvidia-smi -l 2(每 2 秒刷新) |
nvidia-smi dmon |
GPU 详细监控 | nvidia-smi dmon -s pucvmet -d 1 |
py-spy |
Python 进程 profiling | py-spy top --pid <vllm_pid> |
vLLM logs |
请求级耗时统计 | 启动时加 VLLM_LOGGING_LEVEL=DEBUG |
nsys / nsight-compute |
NVIDIA 官方 profiler | 需安装 nsight-systems |
iftop / nethogs |
网络瓶颈排查 | sudo nethogs |
dmesg |
硬件级错误 | `dmesg -T |
3.2 场景一:模型加载失败
Section titled “3.2 场景一:模型加载失败”排查路径:
模型加载失败├── 路径错误? → ls /path/to/model,检查 config.json├── 显存不足? → dmesg | grep -i "out of memory",nvidia-smi 看空闲量├── CUDA 版本不匹配? → 模型所需的 CUDA 版本 vs 系统版本├── 权限问题? → 多用户环境下最常见,检查文件 owner 和权限├── trust-remote-code 未加? → Qwen 系列等国产模型常需要└── 量化方案不支持? → 如 AWQ 需要 awq-dequant 算子,老 vLLM 不支持多用户冲突排查(本机三用户 root/aiadm/sysadmin 各有独立 venv):
# 查看哪个用户的进程占了多少显存nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv
# 对照 ps 找到用户ps -o pid,user,comm -p <pid>3.3 场景二:OOM(显存溢出)
Section titled “3.3 场景二:OOM(显存溢出)”OOM 分两种:加载时 OOM(模型放不下)和 运行时 OOM(KV Cache 膨胀)。
加载时 OOM 排查:
# 1. 查模型权重实际大小du -sh /path/to/model/# 2. 查 GPU 空闲显存nvidia-smi --query-gpu=memory.free --format=csv# 3. 算术验证:权重 + 预留(2GB) < 空闲显存?运行时 OOM 排查:
vLLM 使用 PagedAttention 管理 KV Cache,理论上不会运行时 OOM。但如果出现:
# 查看是否 max-model-len 过大导致 preallocation 失败# vLLM 启动日志中搜:grep -i "memory\|kv cache\|block" /var/log/vllm.log
# 降低 gpu-memory-utilization 或 max-model-len 重试KV Cache 容量估算:
每 token KV Cache 大小 = 2 (K+V) × num_layers × num_heads × head_dim × dtype_bytes
例: Qwen3.5-9B, 32 层, 32 头, 128 dim, FP16(2 bytes)每 token = 2 × 32 × 32 × 128 × 2 = 524288 bytes ≈ 0.5 MB
max-model-len=4096 → 单序列 KV Cache = 4096 × 0.5MB ≈ 2GBmax-num-seqs=64 → 最大 KV Cache 需求 = 64 × 2GB = 128GB ← 远超 24GB!
# 但 vLLM 用 PagedAttention + 动态分配,实际不会 64 个序列都跑满 4096# vLLM 会根据可用显存动态计算能放多少 block,自动限流3.4 场景三:性能不达标
Section titled “3.4 场景三:性能不达标”延迟(Latency)过高排查流程:
延迟高├── Prefill 慢?│ ├── 长输入? → chunked-prefill 是否开启│ ├── batch 过大? → 降低 max-num-batched-tokens│ └── 算力瓶颈? → nvidia-smi dmon 看 sm 利用率├── Decode 慢?│ ├── 显存带宽瓶颈? → Memory-bound,正常现象│ ├── 并发太高? → 降低 max-num-seqs│ └── eager 模式? → 关掉 enforce-eager 启用 CUDA Graph├── 网络慢?│ ├── 客户端到服务端? → iftop / curl -w│ └── API 框架开销? → 绕过 API 直接测 raw inference└── CPU 瓶颈? ├── tokenization 慢? → 用 fast tokenizer └── 多进程争抢? → taskset 绑核实测延迟分解:
# vLLM 自带 benchmark 脚本# 测 prefill + decode 分别的耗时python -m vllm.entrypoints.openai.api_server \ --model <model> ... &
# 用 curl 测端到端延迟curl -s -o /dev/null -w "\DNS: %{time_namelookup}s\n\Connect: %{time_connect}s\n\TTFB: %{time_starttransfer}s\n\Total: %{time_total}s\n" \ -X POST http://localhost:16000/v1/completions \ -H "Content-Type: application/json" \ -d '{"model":"qwen3.5","prompt":"Hello","max_tokens":100}'3.5 场景四:输出质量异常
Section titled “3.5 场景四:输出质量异常”部署阶段的质量问题通常是量化导致的精度损失:
| 量化方案 | 典型精度损失 | 适用场景 |
|---|---|---|
| FP16 / BF16(无量化) | 0% | 基线,质量要求最高 |
| FP8 | <0.5% | 推荐首选,几乎无损 |
| W8A8(权重+激活 INT8) | 0.5-1% | 平衡型,已部署 Qwen3.5-9B |
| AWQ INT4 | 1-2% | 极限压缩,显存紧张时 |
| GGUF Q4_K_M | 2-5% | llama.cpp 生态,非 vLLM |
验证方法: 用标准 benchmark 数据集(MMLU / C-Eval / GSM8K)对比量化前后分数,降幅 >2pp 必须回退。
四、压测:从“能跑”到“能扛多少”
Section titled “四、压测:从“能跑”到“能扛多少””4.1 压测目标——三个核心指标
Section titled “4.1 压测目标——三个核心指标”| 指标 | 定义 | 业界参考 |
|---|---|---|
| TTFT(首 Token 延迟) | 从请求发出到第一个 token 返回的时间 | <500ms 为好 |
| TPOT(每 Token 延迟) | 生成阶段每个 token 的平均耗时 | <50ms 为好 |
| 吞吐量 | 每秒处理的 token 数(input + output) | 越高越好 |
4.2 压测工具
Section titled “4.2 压测工具”方案一:vLLM 自带 benchmark(快速验证)
# vLLM 仓库自带的 benchmark 脚本# 测离线吞吐量python -m vllm.entrypoints.openai.api_server --model <model> &
# 压测脚本(在 vLLM 源码目录)python benchmarks/benchmark_serving.py \ --backend vllm \ --base-url http://localhost:16000 \ --model qwen3.5 \ --num-prompts 1000 \ # 总请求数 --request-rate 10 \ # 每秒发多少请求 --prompt-length 512 \ # 输入长度 --output-length 256 # 输出长度方案二:自定义压测脚本(贴近真实场景)
#!/usr/bin/env python3"""vLLM 推理服务压测脚本 — 模拟真实并发请求"""
import asyncioimport aiohttpimport timeimport jsonimport argparsefrom collections import defaultdict
async def send_request(session, url, model, prompt, max_tokens, request_id, results): """发送单个请求并记录耗时""" payload = { "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": 0.7, "stream": False, }
start = time.perf_counter() try: async with session.post( f"{url}/v1/chat/completions", json=payload, timeout=aiohttp.ClientTimeout(total=120) ) as resp: data = await resp.json() elapsed = time.perf_counter() - start
completion = data.get("choices", [{}])[0] output_tokens = completion.get("usage", {}).get("completion_tokens", 0) prompt_tokens = completion.get("usage", {}).get("prompt_tokens", 0)
results[request_id] = { "status": resp.status, "elapsed": elapsed, "prompt_tokens": prompt_tokens, "output_tokens": output_tokens, "ttft": elapsed / max(output_tokens, 1), # 近似(非 streaming 无法精确测 TTFT) "error": None, } except Exception as e: elapsed = time.perf_counter() - start results[request_id] = { "status": 0, "elapsed": elapsed, "error": str(e), }
async def run_benchmark(url, model, prompts_file, concurrency, total_requests, max_tokens): """主压测循环""" results = {}
# 读取 prompt 列表(或用默认) prompts = [ "请解释什么是张量并行,以及在什么场景下应该使用它。", "写一个 Python 函数,实现快速排序算法。", "用 200 字描述量子计算的基本原理。", "分析以下 SQL 查询的性能瓶颈:SELECT * FROM orders WHERE date > '2024-01-01'", "比较 Redis 和 Memcached 在缓存场景下的优劣。", ] * (total_requests // 5 + 1) prompts = prompts[:total_requests]
connector = aiohttp.TCPConnector(limit=concurrency * 2) async with aiohttp.ClientSession(connector=connector) as session: print(f"🚀 开始压测: {total_requests} 请求, 并发 {concurrency}")
sem = asyncio.Semaphore(concurrency)
async def bounded_send(i): async with sem: await send_request(session, url, model, prompts[i], max_tokens, i, results)
start = time.perf_counter() await asyncio.gather(*[bounded_send(i) for i in range(total_requests)]) total_time = time.perf_counter() - start
print(f"✅ 完成: {total_time:.1f}s")
# 统计结果 analyze_results(results, total_time, concurrency)
def analyze_results(results, total_time, concurrency): """分析压测结果""" success = [r for r in results.values() if r.get("status") == 200] failed = [r for r in results.values() if r.get("status") != 200]
if not success: print("❌ 所有请求失败!") for r in failed[:5]: print(f" 错误: {r.get('error', 'unknown')}") return
latencies = [r["elapsed"] for r in success] latencies.sort()
output_tokens = [r["output_tokens"] for r in success] total_output = sum(output_tokens)
print("\n" + "=" * 60) print(f"压测结果汇总 (并发={concurrency})") print("=" * 60) print(f"总请求数: {len(results)}") print(f"成功: {len(success)} ({len(success)/len(results)*100:.1f}%)") print(f"失败: {len(failed)}") print(f"总耗时: {total_time:.1f}s") print(f"吞吐量: {len(success)/total_time:.1f} req/s") print(f"输出 token: {total_output} ({total_output/total_time:.0f} tok/s)") print() print("延迟分布:") print(f" P50: {latencies[len(latencies)//2]:.3f}s") print(f" P90: {latencies[int(len(latencies)*0.9)]:.3f}s") print(f" P95: {latencies[int(len(latencies)*0.95)]:.3f}s") print(f" P99: {latencies[int(len(latencies)*0.99)]:.3f}s") print(f" Max: {latencies[-1]:.3f}s")
if failed: print("\n失败请求错误:") errors = defaultdict(int) for r in failed: err = r.get("error", "unknown") errors[err] += 1 for err, count in sorted(errors.items(), key=lambda x: -x[1]): print(f" {count}× {err[:80]}") print("=" * 60)
if __name__ == "__main__": parser = argparse.ArgumentParser(description="vLLM 压测工具") parser.add_argument("--url", default="http://localhost:16000") parser.add_argument("--model", default="qwen3.5") parser.add_argument("--concurrency", type=int, default=10) parser.add_argument("--total", type=int, default=100) parser.add_argument("--max-tokens", type=int, default=256) args = parser.parse_args()
asyncio.run(run_benchmark( args.url, args.model, None, args.concurrency, args.total, args.max_tokens ))方案三:流式 TTFT 精确测量
#!/usr/bin/env python3"""精确测量 TTFT(首 Token 延迟)— 需要流式请求"""
import asyncioimport aiohttpimport time
async def measure_ttft(url, model, prompt, max_tokens=100): """流式请求,精确测量第一个 token 到达时间""" payload = { "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": 0.7, "stream": True, # 关键:开流式 }
start = time.perf_counter() ttft = None token_count = 0
async with aiohttp.ClientSession() as session: async with session.post(f"{url}/v1/chat/completions", json=payload) as resp: async for line in resp.aiter_lines(): if line.startswith("data: ") and line != "data: [DONE]": if ttft is None: ttft = time.perf_counter() - start token_count += 1
total = time.perf_counter() - start tpot = (total - ttft) / max(token_count - 1, 1) if token_count > 1 else 0
return { "ttft": ttft, "tpot": tpot, "total": total, "tokens": token_count, }
async def main(): url = "http://localhost:16000" model = "qwen3.5" prompt = "用 200 字解释什么是内存数据库。"
# 跑 20 轮取统计 results = [] for _ in range(20): r = await measure_ttft(url, model, prompt) results.append(r) print(f"TTFT={r['ttft']:.3f}s TPOT={r['tpot']*1000:.1f}ms Tokens={r['tokens']}")
ttfts = sorted([r["ttft"] for r in results]) tpots = sorted([r["tpot"] for r in results])
print(f"\nTTFT: P50={ttfts[10]:.3f}s P90={ttfts[18]:.3f}s") print(f"TPOT: P50={tpots[10]*1000:.1f}ms P90={tpots[18]*1000:.1f}ms")
asyncio.run(main())4.3 压测执行计划
Section titled “4.3 压测执行计划”分三个阶段递进,每阶段在前一阶段通过后执行:
阶段 0:基线测量(单请求)
# 单请求,无并发,测绝对延迟python3 benchmark_ttft.py --concurrency 1 --total 20
# 目标:TTFT < 500ms, TPOT < 50ms# 如果不达标,先在这里定位问题,别急着上加并发阶段 1:阶梯加压(找拐点)
# 逐级加并发,找到延迟剧增的拐点for C in 1 5 10 20 40 64 80 100; do echo "=== Concurrency: $C ===" python3 benchmark.py \ --url http://localhost:16000 \ --model qwen3.5 \ --concurrency $C \ --total $((C * 20)) \ --max-tokens 256 echo sleep 5 # 冷却done
# 观察指标:# - P99 延迟剧增时的并发数 = 软上限# - 错误率 >0 时的并发数 = 硬上限# - 吞吐量不再增长时的并发数 = 饱和点阶段 2:持续压力(测稳定性)
# 在阶段 1 找到的拐点并发下,持续跑 30 分钟python3 benchmark.py \ --url http://localhost:16000 \ --model qwen3.5 \ --concurrency <拐点> \ --total 5000 \ --max-tokens 256
# 关注:# - 是否有延迟逐渐增大(内存泄漏?)# - 是否有随机失败(资源竞争?)# - GPU 显存是否持续增长阶段 3:多模型混合压测
# 同时压三个模型,模拟真实生产负载python3 benchmark.py --url http://localhost:16000 --model qwen3.5 --concurrency 20 --total 500 &python3 benchmark.py --url http://localhost:16001 --model qwenGuard --concurrency 30 --total 1000 &python3 benchmark.py --url http://localhost:45334 --model qwen2.5-vl-7b --concurrency 5 --total 100 &wait
# 关注:GPU 0 的服务是否被影响(共享卡上的资源竞争)4.4 压测监控——眼睛不能只盯压测脚本
Section titled “4.4 压测监控——眼睛不能只盯压测脚本”压测时需要同时监控 GPU 层面的数据:
# 终端 1:跑压测python3 benchmark.py ...
# 终端 2:GPU 监控watch -n 1 'nvidia-smi --query-gpu=index,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,temperature.gpu --format=csv,noheader'
# 终端 3:vLLM 引擎日志(启动时加 VLLM_LOGGING_LEVEL=INFO)# 关注 step 时间、queue 长度、preemption 次数tail -f /proc/$(pgrep -f "vllm.*qwen3.5")/fd/1
# 终端 4:系统级监控htop -p $(pgrep -d, -f vllm)关键观测点:
| 指标 | 健康范围 | 异常信号 |
|---|---|---|
| GPU 利用率 | 60-95% | <30% 说明瓶颈不在 GPU(可能 CPU/IO) |
| GPU 显存 | <95% | >95% 随时可能 OOM |
| GPU 温度 | <80°C | >85°C 会降频 |
| GPU 功耗 | <425W(4090D TDP) | 持续满功耗说明满载 |
| vLLM queue | 大多数时候为 0 | 持续 >0 说明吞吐不够 |
| vLLM preemption | 0 | >0 说明显存不够在抢占 |
五、优化手段:从大到小
Section titled “五、优化手段:从大到小”参考“上层开关抵下层参数”的原则,优化从粗到细排:
L1 部署层(最大杠杆)
Section titled “L1 部署层(最大杠杆)”| 优化 | 效果 | 操作 |
|---|---|---|
| 模型选型匹配显存 | 从源头决定能否跑 | 按显存表选模型 |
| 量化方案选择 | 减小权重,释放 KV Cache 空间 | FP8 > W8A8 > INT4 |
| 多模型 GPU 分配 | 避免争抢 | 一卡一模型 |
L2 调度层
Section titled “L2 调度层”| 优化 | 效果 | 操作 |
|---|---|---|
| chunked-prefill | 长请求不阻塞短请求 | --enable-chunked-prefill |
| prefix-caching | 重复前缀命中缓存 | --enable-prefix-caching |
| max-num-seqs 调优 | 平衡吞吐和延迟 | 压测后定 |
L3 运行时层
Section titled “L3 运行时层”| 优化 | 效果 | 操作 |
|---|---|---|
| CUDA Graph | 减少 kernel launch 开销 | 关掉 --enforce-eager(大模型) |
| NUMA 绑核 | 减少 CPU 跨 NUMA 访问 | numactl --cpunodebind=0 |
| Tokenizer 并行 | 多线程分词不阻塞 | vLLM 默认已用 fast tokenizer |
L4 系统层
Section titled “L4 系统层”| 优化 | 效果 | 操作 |
|---|---|---|
| GPU 持久化模式 | 避免 idle suspend | nvidia-smi -pm 1 |
| PCIe 速度检查 | 确认跑在 Gen4 x16 | nvidia-smi query -a | grep PCIe |
| CPU 频率锁定 | 减少 DVFS 抖动 | cpupower frequency-set -g performance |
六、问题速查表
Section titled “六、问题速查表”把实际部署中最常遇到的问题浓缩成一张表:
| 现象 | 最可能原因 | 快速验证 | 解决方案 |
|---|---|---|---|
| vLLM 启动报 CUDA OOM | 显存被其他进程占用 | nvidia-smi 看 free |
清理 GPU 或换卡 |
| 延迟突然变大 | 并发上来 + prefill 阻塞 | 看 vLLM 日志 queue 长度 | 开 chunked-prefill / 降 max-num-seqs |
| 输出乱码 | 量化精度损失 | 换 FP16 跑对比 | 换更高精度量化 |
| GPU 利用率很低 | 瓶颈在 CPU/网络/tokenizer | nvidia-smi dmon 看 sm 利用率 |
绑核 / 换 fast tokenizer |
| 压测 P99 远高于 P50 | 个别请求被 prefill 阻塞 | 对比 P90 和 P99 差距 | 降 max-num-batched-tokens |
| 多用户互相影响 | 共享 GPU 0 的服务争抢 | nvidia-smi dmon -s u |
迁移到不同 GPU |
| 模型加载极慢 | 从网络盘加载 / mmap | du -sh model_path + free -h |
模型放本地 SSD |
七、结论与行动清单
Section titled “七、结论与行动清单”- 无 NVLink 是最大约束。 四卡之间全走 PCIe,张量并行效率低。最佳策略是 TP=1(单卡单模型),四卡各司其职。
- 当前部署已经比较合理。 三张大模型各占一卡,GPU 0 作为轻量服务专区。扩展空间在 GPU 0 的 14GB 余量。
- 甜蜜区间是 7B-14B 模型单卡部署。 更大的模型(32B+)需要 INT4 量化才能塞进单卡,70B 级别需要 TP=2 但会受限于 PCIe 带宽。
- 阶段 0 基线:用 TTFT 脚本测三台 vLLM 的单请求延迟(预计半天)
- 阶段 1 阶梯压测:从 1 到 100 并发,画延迟-并发曲线(预计半天)
- 阶段 2 稳定性:在拐点并发下跑 30 分钟持续压力(预计 1 小时)
- 阶段 3 混合压测:三台 vLLM 同时压,观察 GPU 0 的相互影响(预计 1 小时)
- 结果归档:所有压测数据、监控截图、日志保存到
/data/benchmark-results/ - 优化迭代:根据压测结果调
max-num-seqs和gpu-memory-utilization
附:所有压测脚本(benchmark.py / benchmark_ttft.py)和原始结果存放在工作区
projects/oghub-4gpu-inference/目录。后续可接续做多模态(VLM)压测和 Embedding/Rerank 吞吐测试。