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×4096KV 池(实测)预算 − KV = non_kvtok/s
0.652662 MiB51 MiB26116.28
0.702867 MiB256 MiB26116.02
0.753072 MiB461 MiB26116.48
0.803277 MiB666 MiB26126.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池 ≤ 0No 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.pyrequest_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 ...")

两个关键点:

  1. 乘数是 total_memory(4096 MiB),不是空闲显存。 所以 gmu=0.70 恒定等于 2867 MiB 预算,跟桌面上还剩多少无关。
  2. 但启动时会拿 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 会随模型、硬件、offloadprofiling 时的激活峰值以及其他运行时显存项变化;只有在这些条件固定时,它才可以近似看成稳定值。


4 12 组扫描

一次跑完 12 组配置(max_model_len=1024 / max_num_seqs=1 为基线)。这张表是全文最重要的证据

#配置gmuoffloadlenseqsbatched结果死法KV 池tok/s
1baseline0.701.010241auto0.25 GiB6.02
2gmu600.601.010241auto#2≤0
3gmu650.651.010241auto0.05 GiB6.28
4gmu750.751.010241auto0.45 GiB6.48
5gmu800.801.010241auto0.65 GiB6.40
6gmu850.851.010241auto#1
7offload00.700.010241auto#2−0.54
8offload20.702.010241auto0.12 GiB2.04
9len20480.701.020481auto(2048)#2−0.37
10seqs40.701.010244auto(4096)#2−0.65
11len2048_mnbt10240.701.02048110240.25 GiB6.60
12seqs4_mnbt10240.701.01024410240.23 GiB24.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 GiB1.01 GiB2.30 GiB0.25 GiB
2.0 GiB1.34 GiB(搬不动了)1.95 GiB0.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 tokensnon_kvKV 池
len 1024 / seqs 110242611 MiB+0.25 GiB
len 204820483246 MiB(+635)−0.37 GiB
seqs 440963533 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_tokensmax_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.ModuleListqwen2_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加速比
baseline16.021.00x
seqs4_mnbt1024424.564.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.pyrequest_memory(),闸门 #1
    • v1/worker/gpu_worker.pydetermine_available_memory(),KV 池推导
    • model_executor/offloader/uva.py — UVA 零拷贝 offload
    • model_executor/models/utils.py:812 / qwen2_5_vl.py:682 — offload 硬上限根因
    • v1/core/kv_cache_utils.py — 闸门 #2 / #3 报错
  • vLLM 官方文档:Conserving Memory · Troubleshooting