单卡 4090 实战:vLLM 部署 Qwen2.5-14B-AWQ 并发压测与避坑

5次阅读
没有评论

共计 3009 个字符,预计需要花费 8 分钟才能阅读完成。

上个月给一个 12 人的小团队搭内部知识库问答,图省事直接 ollama run qwen2.5:14b 跑在机房那台 4090 上,单人测试丝滑得不行。结果上线第二天就被投诉 ” 卡到怀疑人生 ”——我抓了日志才发现,十几个人同时提问时 Ollama 基本在排队串行处理,排在后面的人光等首字就要二十几秒。被迫转向 vLLM 之后,同一张卡的输出吞吐直接涨了七倍多。这篇把整个过程里的命令、算账、报错和压测数据全部摊开讲。

一、Ollama 到底卡在哪儿:瓶颈拆解

先说结论:Ollama 不是慢,是 请求调度策略不适合多用户并发。它默认按显存自动推算 OLLAMA_NUM_PARALLEL,在 24G 卡上跑 14B 通常只给到 1~2,剩下的请求全部进队列。ollama ps 能看到 PARALLEL 列,你可以手动调高,但同时它会按并行数均分上下文长度,长文档 RAG 场景下很容易被截断。

而 vLLM 的核心是 PagedAttention + Continuous Batching:请求不再按批次攒满再跑,而是每生成一个 token 就重新调度一次,短请求生成完立刻把显存块还给新来的长请求。对 ” 多人同时问、回答长度参差 ” 的问答场景,这个差异是数量级的。

我这台机器的基础环境:

# 显卡:RTX 4090 24G(无 NVLink,单卡)nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
# NVIDIA GeForce RTX 4090, 24564 MiB, 550.107.02
# 系统:Ubuntu 22.04.4 LTS / Docker 27.1 / nvidia-container-toolkit 1.16

二、启动配置与参数逐个解析

模型我选的是 Qwen2.5-14B-Instruct-AWQ,4bit 量化后权重约 9.6GB,留出足够的 KV Cache 空间。用官方镜像,版本必须锁死,vLLM 的小版本之间参数行为变过好几次:

docker run -d --gpus all \
  --shm-size=16g \
  -p 8000:8000 \
  -v /data/models:/models:ro \
  --restart unless-stopped \
  --name vllm-qwen14b \
  vllm/vllm-openai:v0.6.6 \
  --model /models/Qwen2.5-14B-Instruct-AWQ \
  --quantization awq_marlin \
  --dtype float16 \
  --max-model-len 16384 \
  --gpu-memory-utilization 0.92 \
  --max-num-seqs 64 \
  --enable-prefix-caching \
  --served-model-name qwen14b

几个参数值得单独说,全是我踩过坑的地方:

  • --shm-size=16g:Docker 默认 /dev/shm 只有 64MB,vLLM 的多进程 worker 加载权重时会直接总线错误退出,报错信息极其误导(RuntimeError: DataLoader worker (pid xxx) is killed by signal: Bus error)。这条不加,后面全白搭。
  • --quantization awq_marlin:4090 是 Ada 架构(SM 8.9),Marlin 内核能吃满 INT4 的 Tensor Core。写 awq 也能跑,但实测 TPOT 差 15% 左右。--quantization 其实可以省略让 vLLM 自检,但我建议显式写,出问题时排查路径更清晰。
  • --max-model-len 16384:这是启动时最容易失败的一项,见下节算账。
  • --enable-prefix-caching:RAG 场景必开。同一份知识库前缀被反复命中,实测能砍掉 40% 以上的 TTFT。

算一笔 KV Cache 的账

Qwen2.5-14B 是 GQA 结构,40 层、8 个 KV Head、head_dim 128、fp16 存储。单个 token 的 KV Cache 占用:

2 (K+V) × 40 层 × 8 head × 128 dim × 2 Bytes = 163,840 Bytes ≈ 160 KB / token

24GB × 0.92 ≈ 22.1GB 显存池,减去 9.6GB 权重和约 1GB 的激活 /CUDA Context 开销,剩给 KV Cache 大约 11.5GB,折合 约 7.3 万 token 的总容量。

这意味着:--max-model-len 32768 是绝对起不来的,启动阶段就会给你甩这段报错:

ValueError: The model's max seq len (32768) is larger than the maximum
number of tokens that can be stored in KV cache (73536 tokens).

注意这个报错里的数字是 ” 总量 ”,它只校验单条序列能不能塞下。真正的并发上限是总量除以平均序列长度——16K 上下文 + 64 并发理论上要 100 万 token,远超容量。所以 --max-num-seqs 64 只是调度器的队列上限,实际跑到长文本时会被 KV 块限流,请求开始排队。这也是为什么压测里并发 32 之后 TTFT 曲线陡升。

三、并发压测:真实数据长什么样

压测脚本用 aiohttp 直接打 OpenAI 兼容接口,测三个指标:TTFT(首 token 延迟)、TPOT(每 token 输出耗时)、稳态输出吞吐。请求固定 max_tokens=512,prompt 长度控制在 300~500 token 区间模拟真实问答。

import asyncio, time, aiohttp, statistics

URL = "http://127.0.0.1:8000/v1/chat/completions"

async def one(session, sem, res):
async with sem:
payload = {"model": "qwen14b", "stream": True, "max_tokens": 512,
"messages": [{"role": "user", "content": PROMPT}]}
t0 = time.perf_counter(); ttft = None; n = 0
async with session.post(URL, json=payload) as r:
async for line in r.content:
if not line.startswith(b"data:"): continue
if line.strip() == b"data: [DONE]": break
if ttft is None: ttft = time.perf_counter() - t0
n += 1
total = time.perf_counter() - t0
res.append((ttft, (total - ttft) / max(n - 1, 1), n / (total - ttft)))

async def bench(concurrency):
sem, res = asyncio.Semaphore(concurrency), []
async with aiohttp.ClientSession() as s:
t = time.perf_counter()
await asyncio.gather(*[one(s, sem, res) for _ in range(concurrency * 4)])
wall = time.perf_counter() - t
print(f" 并发 {concurrency}:

正文完
 0
评论(没有评论)