跳到主要内容

L40S 大模型部署实录②:48GB 显存塞两个模型——7 个让我崩溃的坑

阅读需 3 分钟

Free memory: 0.41 GiB。这是我第一次尝试在一张卡上跑两个模型时,vLLM 启动日志里的数字。然后它 crash 了。从第一次 OOM 到三组模型稳定运行,中间经历了 7 种不同的失败姿势。这篇是完整的踩坑记录。

目标:一卡双模型

需求很明确:每张 L40S 上跑一个 LLM(用于对话/推理)+ 一个 Embedding 模型(用于 RAG 检索)。三张卡,三组模型。

看起来显存够用:

LLM (27B FP8):     ~28GB 权重 + ~10GB KV Cache = ~38GB
Embedding (0.6B): ~1.2GB 权重 + ~0.3GB KV Cache = ~1.5GB
总计: ~39.5GB / 48GB,还剩 8.5GB

实际情况远没这么简单。

坑 1:K8s GPU 隔离——两个容器不能各声明一张卡

最初的想法:Pod 里两个容器,各声明 nvidia.com/gpu: 1

错了。 K8s NVIDIA Device Plugin 按容器分配 GPU,不是按 Pod。两个容器各声明 1 张 GPU = Pod 需要 2 张 GPU。

正确做法:只有 Embedding 容器声明 nvidia.com/gpu: 1,LLM 容器不声明 GPU,通过 NVIDIA_VISIBLE_DEVICES=all 访问所有 GPU。

坑 2:NVIDIA_VISIBLE_DEVICES=all 看到的是所有卡

LLM 容器设了 NVIDIA_VISIBLE_DEVICES=all 后,它能看到节点上所有 GPU,不只是 Embedding 拿到的那张。vLLM 默认用 cuda:0,但 cuda:0 可能是别的 Pod 正在用的卡。

结果:vLLM 启动时发现 cuda:0 只剩 0.41GB 可用显存,直接 crash。

ValueError: Free memory on device cuda:0 (0.41/44.39 GiB) 
is less than desired GPU memory utilization (0.55, 24.42 GiB)

解决方案:GPU UUID 文件协调

Embedding 容器启动后,把自己拿到的 GPU UUID 写到共享 Volume:

# Embedding 启动脚本
GPU_UUID=$(nvidia-smi --query-gpu=gpu_uuid --format=csv,noheader)
echo "$GPU_UUID" > /mnt/models/.gpu-uuid-$MODEL_NAME

LLM 容器读取 UUID,转换成物理索引:

# LLM 启动脚本
GPU_UUID=$(cat /mnt/models/.gpu-uuid-$MODEL_NAME)
GPU_INDEX=$(nvidia-smi --query-gpu=index,gpu_uuid --format=csv,noheader \
| grep "$GPU_UUID" | cut -d',' -f1)
export CUDA_VISIBLE_DEVICES=$GPU_INDEX

坑 3:CUDA_VISIBLE_DEVICES 不接受 UUID

一开始我直接把 UUID 传给 CUDA_VISIBLE_DEVICES

export CUDA_VISIBLE_DEVICES=GPU-47ecdc0c-acfd-cb3a-2768-b3da530ff5c1

vLLM 报错:

ValueError: invalid literal for int() with base 10: 'GPU-47ecdc...'

必须转成整数索引(0/1/2/3)。

坑 4:gpu-memory-utilization 是各算各的

两个 vLLM 进程各自独立计算 gpu-memory-utilization,都是基于整卡 48GB 算的。

如果 Embedding 设 0.08(3.8GB),LLM 设 0.92(43.2GB),加起来 47GB,看起来刚好。但每个进程还有 ~0.5GB 的 CUDA context 开销,实际总需求超过 48GB → OOM。

最终配置:Embedding 0.08 + LLM 0.87 = 0.95,留 5% 给双 CUDA context。

坑 5:MoE 模型加载全部参数

Qwen3.6-35B-A3B-FP8 标称"3B 活跃参数",但它有 128 个 Expert,总参数 35B。vLLM 加载时把全部 35B 权重(~34GB)都放到 GPU 上,不是只放 3B。

这意味着 35B MoE 在单卡 48GB 上非常紧张:34GB 权重 + CUDA context + KV Cache,几乎没有余量。最终只分配到 2.3GB KV Cache(约 53K tokens),勉强能用。

坑 6:Qwen3.6 的 torch.compile 吃显存

Qwen3.6 加载完 34GB 权重后,torch.compile 阶段会额外占用显存做 CUDA Graph capture。在 48GB 卡上,这个阶段直接 OOM。

尝试过的方案:

  1. --compilation-config '{"cudagraph_capture_sizes": [1,2,4,8]}' — 减少 capture 数量,从 51 个降到 4 个。但 Helm YAML 转义 JSON 时出错,没生效。
  2. --enforce-eager — 完全跳过 torch.compile 和 CUDA Graph。这个有效。

代价是推理速度下降:TPOT 从理论的 ~55ms 涨到 88ms。但至少能跑起来。

坑 7:Probe 超时

多容器 Pod 的 LLM 容器有 90 秒启动延迟(等 Embedding 先启动),加上模型加载时间,总启动时间可能超过 5 分钟。

Kubernetes 的 liveness probe 默认 initialDelaySeconds 太短,模型还没加载完就被 kill 了,进入 CrashLoopBackOff。

最终配置:

模型initialDelaySeconds
27B Dense180s
35B MoE + MTP600s
31B Dense360s

最终显存分布

三组模型全部 2/2 Running,0 restarts 后的 nvidia-smi

GPU已用剩余Pod
044,600 MiB (96.8%)859 MiBQwen3.6-27B + Embedding
144,322 MiB (96.2%)1,136 MiBQwen3.6-35B + Embedding
244,398 MiB (96.5%)1,060 MiBGemma4-31B + Embedding

三张卡都用到了 96% 以上。这就是为什么调参过程如此痛苦——每一个百分点的显存分配都可能决定模型能不能启动。

经验总结

  1. K8s 里多容器共享 GPU,只能一个容器声明 GPU 资源,另一个用 NVIDIA_VISIBLE_DEVICES=all + UUID 文件协调
  2. gpu-memory-utilization 是各算各的,两个进程加起来不能超过 0.95
  3. MoE 模型按总参数算显存,不是活跃参数
  4. enforce-eager 是最后的救命稻草,牺牲速度换稳定性
  5. Probe 超时要按最慢的模型配置,35B MoE 需要 10 分钟

下一篇讲性能基准测试——同样的硬件,不同模型的吞吐差异有多大。