Skip to content

四卡 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 GPU3
GPU0 X PIX PXB PXB
GPU1 PIX X PXB PXB
GPU2 PXB PXB X PXB
GPU3 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,要么重新洗牌。

  • CPU:AMD EPYC 9554,双路 56 核 / 112 物理核 / 224 线程
  • 内存:1TB(已用 78GB,剩余 929GB 可用)
  • 磁盘:系统盘 437GB(71%),数据盘 /data 3.6TB(26%)

CPU 和内存远不是瓶颈。推理服务的瓶颈在 GPU 显存和算力,CPU/内存可以放心地当作“无限资源”来规划。


24GB / 卡是物理限制。模型加载到 GPU 后,显存占用大致分三块:

总显存 = 模型权重 + KV Cache + 运行开销(CUDA context / 临时张量)
  • 模型权重:固定开销,加载即占用。量化能减。
  • KV Cache:随并发和上下文长度线性增长。vLLM 的 gpu-memory-utilization 控制预分配比例。
  • 运行开销:CUDA context ~300MB,vLLM 调度器、通信缓冲等 ~500MB-1GB。
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。

按显存从大到小排列常见推理模型的单卡需求:

模型 参数量 精度 权重大小 推荐量化 量化后大小 单卡可行性
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 量化后单卡。

基于现有服务和未来扩展,推荐如下分配:

┌─────────┬────────────────────────────┬────────────┬───────────┐
│ 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)

当前部署的启动参数和优化建议:

Terminal window
# 主力 LLM (GPU 2) — Qwen3.5-9B w8a8
vllm 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-4B
vllm 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)、性能不达标、输出质量异常。这里整理一套系统性的调试流程。

工具 用途 命令
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

排查路径:

模型加载失败
├── 路径错误? → 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):

Terminal window
# 查看哪个用户的进程占了多少显存
nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv
# 对照 ps 找到用户
ps -o pid,user,comm -p <pid>

OOM 分两种:加载时 OOM(模型放不下)和 运行时 OOM(KV Cache 膨胀)。

加载时 OOM 排查:

Terminal window
# 1. 查模型权重实际大小
du -sh /path/to/model/
# 2. 查 GPU 空闲显存
nvidia-smi --query-gpu=memory.free --format=csv
# 3. 算术验证:权重 + 预留(2GB) < 空闲显存?

运行时 OOM 排查:

vLLM 使用 PagedAttention 管理 KV Cache,理论上不会运行时 OOM。但如果出现:

Terminal window
# 查看是否 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 ≈ 2GB
max-num-seqs=64 → 最大 KV Cache 需求 = 64 × 2GB = 128GB ← 远超 24GB!
# 但 vLLM 用 PagedAttention + 动态分配,实际不会 64 个序列都跑满 4096
# vLLM 会根据可用显存动态计算能放多少 block,自动限流

延迟(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 绑核

实测延迟分解:

Terminal window
# 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}'

部署阶段的质量问题通常是量化导致的精度损失:

量化方案 典型精度损失 适用场景
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 “四、压测:从“能跑”到“能扛多少””
指标 定义 业界参考
TTFT(首 Token 延迟) 从请求发出到第一个 token 返回的时间 <500ms 为好
TPOT(每 Token 延迟) 生成阶段每个 token 的平均耗时 <50ms 为好
吞吐量 每秒处理的 token 数(input + output) 越高越好

方案一:vLLM 自带 benchmark(快速验证)

Terminal window
# 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 asyncio
import aiohttp
import time
import json
import argparse
from 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 asyncio
import aiohttp
import 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())

分三个阶段递进,每阶段在前一阶段通过后执行:

阶段 0:基线测量(单请求)

Terminal window
# 单请求,无并发,测绝对延迟
python3 benchmark_ttft.py --concurrency 1 --total 20
# 目标:TTFT < 500ms, TPOT < 50ms
# 如果不达标,先在这里定位问题,别急着上加并发

阶段 1:阶梯加压(找拐点)

Terminal window
# 逐级加并发,找到延迟剧增的拐点
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:持续压力(测稳定性)

Terminal window
# 在阶段 1 找到的拐点并发下,持续跑 30 分钟
python3 benchmark.py \
--url http://localhost:16000 \
--model qwen3.5 \
--concurrency <拐点> \
--total 5000 \
--max-tokens 256
# 关注:
# - 是否有延迟逐渐增大(内存泄漏?)
# - 是否有随机失败(资源竞争?)
# - GPU 显存是否持续增长

阶段 3:多模型混合压测

Terminal window
# 同时压三个模型,模拟真实生产负载
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 层面的数据:

Terminal window
# 终端 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 说明显存不够在抢占

参考“上层开关抵下层参数”的原则,优化从粗到细排:

优化 效果 操作
模型选型匹配显存 从源头决定能否跑 按显存表选模型
量化方案选择 减小权重,释放 KV Cache 空间 FP8 > W8A8 > INT4
多模型 GPU 分配 避免争抢 一卡一模型
优化 效果 操作
chunked-prefill 长请求不阻塞短请求 --enable-chunked-prefill
prefix-caching 重复前缀命中缓存 --enable-prefix-caching
max-num-seqs 调优 平衡吞吐和延迟 压测后定
优化 效果 操作
CUDA Graph 减少 kernel launch 开销 关掉 --enforce-eager(大模型)
NUMA 绑核 减少 CPU 跨 NUMA 访问 numactl --cpunodebind=0
Tokenizer 并行 多线程分词不阻塞 vLLM 默认已用 fast tokenizer
优化 效果 操作
GPU 持久化模式 避免 idle suspend nvidia-smi -pm 1
PCIe 速度检查 确认跑在 Gen4 x16 nvidia-smi query -a | grep PCIe
CPU 频率锁定 减少 DVFS 抖动 cpupower frequency-set -g performance

把实际部署中最常遇到的问题浓缩成一张表:

现象 最可能原因 快速验证 解决方案
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

  1. 无 NVLink 是最大约束。 四卡之间全走 PCIe,张量并行效率低。最佳策略是 TP=1(单卡单模型),四卡各司其职。
  2. 当前部署已经比较合理。 三张大模型各占一卡,GPU 0 作为轻量服务专区。扩展空间在 GPU 0 的 14GB 余量。
  3. 甜蜜区间是 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 吞吐测试。