4GB 显存跑 vLLM:三道显存闸门与 12 组实测
环境声明:RTX 3050 Ti Laptop 4GB + WSL2 (Ubuntu-24.04) · vLLM 0.28.0(V1 引擎)· Qwen2.5-VL-3B-Instruct-AWQ(AWQ int4)。
1 先说结论
在 4GB 卡上排查本文这类 vLLM 显存启动问题,可以先看三条关系:声明的显存预算能不能兑现、扣掉 non_kv 后还有没有 KV 空间,以及剩余 KV 能不能装下一条最大长度序列。
这个模型的账本,实测长这样(non_kv 由「预算 − KV 池」反推,四个点基本一致):
gmu | 预算 = gmu×4096 | KV 池(实测) | 预算 − KV = non_kv | tok/s |
|---|---|---|---|---|
| 0.65 | 2662 MiB | 51 MiB | 2611 | 6.28 |
| 0.70 | 2867 MiB | 256 MiB | 2611 | 6.02 |
| 0.75 | 3072 MiB | 461 MiB | 2611 | 6.48 |
| 0.80 | 3277 MiB | 666 MiB | 2612 | 6.40 |
这张表在说什么:在只改变 gmu、其他配置保持不变的这四个实验点中,non_kv 基本是个常数。这是因为 gmu 主要改变 vLLM 声明的显存预算,而这组实验中的权重、profiling 激活峰值以及其他运行时显存项没有变化。右边那列 tok/s 在KV 池扩大约 13 倍时也没有明显的单调变化。这里需要注意:变大的是 KV cache 的预留容量,并不是当前请求实际访问的 KV working set。原因后面会继续分析。
2 三种报错关键词
本文关注的这条”显存预算 → KV cache 初始化”路径上,启动失败基本可以归纳成三道闸门,看报错关键词通常就能判断死在哪一环:
| 闸门 | 触发条件 | 报错关键词 |
|---|---|---|
| #1 预算声明 | 预算 > 空闲显存 | Free memory ... is less than desired GPU memory utilization |
| #2 KV 归零 | KV池 ≤ 0 | No available memory for the cache blocks |
| #3 装不下序列 | 0 < KV池 < KV需求 | To serve at least one request with the model's max seq len ... |
三种门禁我都实测撞到了(12 组扫描里 #1 两次、#2 五次、#3 零次——因为 #3 的窗口太窄,见第四节)。
最容易混淆的一点:在 #1 这条校验里,cpu_offload_gb 并不能帮你降低 gpu_memory_utilization 声明出来的预算。这里比较的是「gmu 对应的目标预算」和启动时的「空闲显存」。我一开始看到 Free memory ... less than desired 就条件反射地去加 cpu_offload_gb,但这个参数并不改变这条判断里的目标预算,所以加了一倍也没解决问题。
为什么是「启动期失败」,不是 OOM:本文这三道闸门对应的都是启动期的显存校验 / KV cache 初始化失败,它们本身并不等价于 torch.OutOfMemoryError。真正的 CUDA OOM 仍然可能发生在模型加载、profiling、CUDA Graph capture 或运行期实际负载中。我的 12 组实验里没有遇到运行期 OOM,但启动校验失败出现了 7 次。如果把这些失败全部统称成”OOM”,反而很难从实验结果里看出规律。
3 源码层面
看 v1/worker/utils.py 的 request_memory():
requested_memory = math.ceil(
init_snapshot.total_memory * cache_config.gpu_memory_utilization
)
if init_snapshot.free_memory < requested_memory:
raise ValueError("Free memory on device ... is less than desired "
"GPU memory utilization ...")
两个关键点:
- 乘数是
total_memory(4096 MiB),不是空闲显存。 所以gmu=0.70恒定等于 2867 MiB 预算,跟桌面上还剩多少无关。 - 但启动时会拿
free_memory去校验这个声明。 这就是矛盾所在:预算按总显存算,能不能兑现却按空闲显存查。
我遇到的那次报错正是这个矛盾:gmu=0.85 声明的预算是 3482 MiB,而空闲显存只有 3287 MiB,差 195 MiB,直接拒绝启动,模型还没有进入正常推理。
non_kv 则是实测出来的,不是估的(v1/worker/gpu_worker.py):
self.available_kv_cache_memory_bytes = (
self.requested_memory # gmu × 总显存
- profile_result.non_kv_cache_memory # ← 用 dummy 输入真跑一遍 forward 量出来
- cudagraph_memory_estimate_applied # CUDA graph 预留
)
所以 non_kv 会随模型、硬件、offload、profiling 时的激活峰值以及其他运行时显存项变化;只有在这些条件固定时,它才可以近似看成稳定值。
4 12 组扫描
一次跑完 12 组配置(max_model_len=1024 / max_num_seqs=1 为基线)。这张表是全文最重要的证据:
| # | 配置 | gmu | offload | len | seqs | batched | 结果 | 死法 | KV 池 | tok/s |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | baseline | 0.70 | 1.0 | 1024 | 1 | auto | ✅ | — | 0.25 GiB | 6.02 |
| 2 | gmu60 | 0.60 | 1.0 | 1024 | 1 | auto | ❌ | #2 | ≤0 | — |
| 3 | gmu65 | 0.65 | 1.0 | 1024 | 1 | auto | ✅ | — | 0.05 GiB | 6.28 |
| 4 | gmu75 | 0.75 | 1.0 | 1024 | 1 | auto | ✅ | — | 0.45 GiB | 6.48 |
| 5 | gmu80 | 0.80 | 1.0 | 1024 | 1 | auto | ✅ | — | 0.65 GiB | 6.40 |
| 6 | gmu85 | 0.85 | 1.0 | 1024 | 1 | auto | ❌ | #1 | — | — |
| 7 | offload0 | 0.70 | 0.0 | 1024 | 1 | auto | ❌ | #2 | −0.54 | — |
| 8 | offload2 | 0.70 | 2.0 | 1024 | 1 | auto | ✅ | — | 0.12 GiB | 2.04 |
| 9 | len2048 | 0.70 | 1.0 | 2048 | 1 | auto(2048) | ❌ | #2 | −0.37 | — |
| 10 | seqs4 | 0.70 | 1.0 | 1024 | 4 | auto(4096) | ❌ | #2 | −0.65 | — |
| 11 | len2048_mnbt1024 | 0.70 | 1.0 | 2048 | 1 | 1024 | ✅ | — | 0.25 GiB | 6.60 |
| 12 | seqs4_mnbt1024 | 0.70 | 1.0 | 1024 | 4 | 1024 | ✅ | — | 0.23 GiB | 24.56 |
三个边界可以精确算出来:
#2 边界(KV 归零) gmu = 2611 / 4096 = 0.6375 → 0.60 死、0.65 活 ✅
#1 边界(预算超空闲) gmu = 3287 / 4096 = 0.8025 → 0.80 擦边活、0.85 死 ✅
#3 窄缝 0.6375 ~ 0.6462(约 0.009 宽) → 12 组全部错过
被数据打脸的两个直觉
直觉一:offload 越多,KV 池越大 ❌
我原本按线性外推,以为 offload=2.0 能换出 ~1300 MiB 的 KV 池。实测打脸:
| offload 请求 | 实际搬走 | 显存内权重 | KV 池 |
|---|---|---|---|
| 1.0 GiB | 1.01 GiB | 2.30 GiB | 0.25 GiB |
| 2.0 GiB | 1.34 GiB(搬不动了) | 1.95 GiB | 0.12 GiB ← 反而更小 |
多搬走 0.33 GiB 权重,KV 池反而缩水一半(non_kv 从 2611 涨到 2744 MiB)。机理待查(怀疑是 UVA 锁页内存在 WSL2/WDDM 下被计入显存账本),但现象是确定的:在 4GB 卡上,offload 超过 ~1.0 GiB 是负收益。
直觉二:KV 池越大,速度越快 ❌
gmu 从 0.65 加到 0.80,KV 池从 51 MiB 涨到 666 MiB(13 倍),而 tok/s 是 6.28 / 6.02 / 6.48 / 6.40,这没有明显的单调提升趋势。
这说明在本文这个固定短上下文 workload 下,吞吐并不受 KV cache 预留容量主导。更符合数据的解释是:单序列 decode 更容易受到权重搬运和内存带宽路径限制;额外预留、但实际没有被请求使用到的 KV 空间,并不会自动转化成更高吞吐。这也解释了为什么单纯调大 gmu 对这个 1024 上下文场景帮助很小:它主要增加的是可容纳的 KV 容量,而不是单请求 decode 的计算速度。
5 两个隐藏变量
5.1 max_num_batched_tokens 会偷偷抬高 non_kv
第 9、10 组的参数改的全是”长度/并发”,死法却还是 #2(KV 归零),这很反直觉:max_model_len 翻倍,单纯从 KV 容量来看,额外需求并没有大到足以解释数百 MiB 的差距。
真相是:在本文这几组小规模配置中,默认的 max_num_batched_tokens 最终受到了 max_model_len × max_num_seqs 上限的约束,因此分别落在 1024、2048 和 4096。这个 token budget 又会影响 profiling 时 forward 的激活峰值,从而把 non_kv 撑大:
| 配置 | 推导出的 batched tokens | non_kv | KV 池 |
|---|---|---|---|
| len 1024 / seqs 1 | 1024 | 2611 MiB | +0.25 GiB ✅ |
| len 2048 | 2048 | 3246 MiB(+635) | −0.37 GiB ❌ |
| seqs 4 | 4096 | 3533 MiB(+922) | −0.65 GiB ❌ |
5.2 显式压回 1024
把 max_num_batched_tokens 显式压回 1024,两条全部救活:
--max-model-len 2048 --max-num-batched-tokens 1024 # 11 号:✅ KV 池 0.25 GiB
--max-num-seqs 4 --max-num-batched-tokens 1024 # 12 号:✅ KV 池 0.23 GiB
这是 4GB 卡上跑长上下文 / 高并发的正确姿势:用 chunked prefill 把 max_num_batched_tokens 与 max_model_len 解耦。
6 为什么 offload 有 1.34 GiB 的硬上限
请求 cpu_offload_gb=2.0,日志却显示只搬走了 1.34 GiB:
INFO [uva.py:58] Total CPU offloaded parameters: 1.34
原因在 get_offloader().wrap_modules() 的调用点上——它只在构建解码层时被调用:
# model_executor/models/utils.py:812 ← 被 qwen2.py:375 的 make_layers 调用
modules = torch.nn.ModuleList(
[PPMissingLayer() for _ in range(start_layer)]
+ get_offloader().wrap_modules(
layer_fn(prefix=f"{prefix}.{idx}") for idx in range(start_layer, end_layer)
)
+ [PPMissingLayer() for _ in range(end_layer, num_hidden_layers)]
)
而 vision tower 用的是普通 nn.ModuleList(qwen2_5_vl.py:682),从不经过 offloader。所以这台机器的权重账本是:
| 组成 | 大小 | 可 offload |
|---|---|---|
| 36 个解码层(AWQ int4) | 1.34 GiB | ✅ 唯一可搬的 |
| vision tower(bf16,未量化) | ~1.29 GiB | ❌ 永远搬不走 |
| embedding(tied,bf16) | ~0.62 GiB | ❌ 永远搬不走 |
| 合计 | 3.32 GiB | — |
表中三个主要部分按近似值相加约 3.25 GiB,与日志中的总体权重占用仍有少量差额,这里没有继续细分其他小模块和运行时项。这里真正重要的不是精确到几十 MiB,而是结构:能被当前 UVA offloader 搬走的主要是 decoder layers,而不是整份模型权重。
我原本猜”如果恰好 offload 掉 vision tower,这 1 GiB 对纯文本 decode 的速度几乎零代价”。但在当前实现里,vision tower 压根不在这条 offload 候选路径里,因此这个优化方向实际上不存在。
7 意外收获
| 配置 | 并发 | tok/s | 加速比 |
|---|---|---|---|
| baseline | 1 | 6.02 | 1.00x |
| seqs4_mnbt1024 | 4 | 24.56 | 4.08x |
总吞吐接近 4 倍。 这里的 tok/s 指的是并发请求的 aggregate output throughput,而不是”每条请求都快了 4 倍”。这个现象跟前一节的观察是自洽的:单序列 decode 时,每个输出 token 都需要承担较高的模型权重访问 / 搬运成本;而 4 条请求一起进行 decode 时,同一批矩阵计算可以同时服务多个 token,batching 摊薄了每个输出 token 对权重访问的平均成本。因此系统总吞吐可以随着 batch size 显著提高。
这也是 continuous batching 能提高 GPU 利用率的核心原因之一:对于偏 memory-bound 的 decode workload,提高有效 batch size 可以让同一次权重访问服务更多 token,从而提高硬件利用率。不过具体能提高到多少取决于模型、上下文长度、batch size、PCIe / 显存带宽和调度状态;本文没有单独测量 GPU utilization,因此这里只讨论吞吐结果。
常见问题 FAQ
Q1:为什么 gmu=0.85 报错,调小到 0.80 就好了?
因为 0.85 声明的预算 3482 MiB 超过了空闲显存 3287 MiB(差 195 MiB),撞的是闸门 #1——纯校验失败,跟模型大小、offload 都无关。0.80 的预算 3277 MiB 刚好卡进 3287 MiB,余量只有 10 MiB,属于擦边配置,换个桌面占用就可能翻车。
Q2:我把 cpu_offload_gb 从 1.0 加到 2.0,为什么反而更慢了?
两个原因叠加:一是只能搬走 1.34 GiB(vision tower 和 embedding 不经过 offloader),请求 2.0 是无效的;二是被 offload 的权重要走 PCIe(笔记本 ~7 GB/s),而显存带宽是 192 GB/s。实测 tok/s 从 6.02 掉到 2.04(两次复现分别为 1.81 / 2.04)。
Q3:KV 池 18,896 token 和 1,424 token,为什么 tok/s 一样?
因为解码是权重带宽瓶颈,不是 KV 瓶颈。每生成一个 token 都要把权重读一遍,GPU 大部分时间在等显存——KV 池大小不参与这个循环。
Q4:桌面开着浏览器,会影响 vLLM 的显存计算吗?
分两件事:不会抬高 non_kv(profiling 测的是前后差值,既有占用被约掉);但会撞 #1 闸门(空闲显存变少,高 gmu 声明兑现不了)。所以记录结果时基线要记,但没必要为此中止实验。
Q5:max_model_len 从 1024 调到 2048,为什么 KV 需求才涨 36 MiB,却启动失败了?
因为 vLLM 会自动把 max_num_batched_tokens 推导成 2048,撑大了 profiling 的激活峰值,让 non_kv 涨了 635 MiB。解法是显式 --max-num-batched-tokens 1024,用 chunked prefill 把两者解耦。
参考来源
- 本机源码(vLLM 0.28.0,版本严格对口):
v1/worker/utils.py—request_memory(),闸门 #1v1/worker/gpu_worker.py—determine_available_memory(),KV 池推导model_executor/offloader/uva.py— UVA 零拷贝 offloadmodel_executor/models/utils.py:812/qwen2_5_vl.py:682— offload 硬上限根因v1/core/kv_cache_utils.py— 闸门 #2 / #3 报错
- vLLM 官方文档:Conserving Memory · Troubleshooting