06 · 部署加载链路与磁盘规划
文章目录
- [06 · 部署加载链路与磁盘规划](#06 · 部署加载链路与磁盘规划)
-
- [0. 速览](#0. 速览)
- [1. 部署加载链路](#1. 部署加载链路)
-
- [1.1 启动四阶段](#1.1 启动四阶段)
- [1.2 耗时的两个决定变量](#1.2 耗时的两个决定变量)
- [1.3 CPU 侧反序列化不是瓶颈](#1.3 CPU 侧反序列化不是瓶颈)
- [2. 页缓存:决定 7 分钟还是 30 分钟](#2. 页缓存:决定 7 分钟还是 30 分钟)
-
- [2.1 热与冷是两种完全不同的负载状态](#2.1 热与冷是两种完全不同的负载状态)
- [2.2 预读机制](#2.2 预读机制)
- [2.3 为什么不做"串行预读再启动"](#2.3 为什么不做"串行预读再启动")
- [3. 磁盘规划](#3. 磁盘规划)
-
- [3.1 原则](#3.1 原则)
- [3.2 磁盘速度测试](#3.2 磁盘速度测试)
- [3.3 部署期磁盘布局](#3.3 部署期磁盘布局)
- [4. 内存护栏](#4. 内存护栏)
-
- [4.1 容器 `/dev/shm`](#4.1 容器
/dev/shm) - [4.2 cgroup 内存护栏:两个设置配套](#4.2 cgroup 内存护栏:两个设置配套)
- [4.3 OOM 善后](#4.3 OOM 善后)
- [4.1 容器 `/dev/shm`](#4.1 容器
- [5. I/O 纪律与判活](#5. I/O 纪律与判活)
-
- [5.1 加载期间不要抢 I/O](#5.1 加载期间不要抢 I/O)
- [5.2 判活三件套](#5.2 判活三件套)
- [5.3 `/proc/<pid>/io` 的读法](#5.3
/proc/<pid>/io的读法)
- [6. 踩坑与证否](#6. 踩坑与证否)
- [7. 复现命令](#7. 复现命令)
0. 速览
- 权重 390.6 GiB / 25 分片,全部在 NFS 上。实测单流 0.72 GB/s,带宽下限 ≈ 375 s。
- 页缓存命中率是实际耗时的决定性因素:热(99.8%)→ 427 s ;冷(≈17%)→ 1802 s。差异 4.2×。
- 每次启动把检查点读两遍(第二遍是 MTP 草稿头,10~38 s),但启动耗时 100% 由第一遍决定。
- 磁盘规划原则:权重只读挂载进容器;I/O 路径的判断先测再定,不要带"网络盘一定慢"之类的先验。
- 内存护栏必须
MemoryMax与memory.oom.group=1配套设置,缺一不可。 - 判活必须看三件套(日志 + CPU 时间 +
/proc/<pid>/io)同时停滞 ≥ 20 min,单看任何一项都会误判。
1. 部署加载链路
1.1 启动四阶段
一次冷启动(页缓存 99.8% 热)的耗时分解如下:
| 阶段 | 实测耗时 | 备注 |
|---|---|---|
docker run → 8 个 worker 就绪 |
90~150 s | 含 Triton 后端选择,基本不可压缩 |
| vLLM 预读检查点进页缓存 | 28 s(热)/ ~400 s(冷) | 内建机制,无需额外干预 |
Model loading took |
427 s (热)/ 1802 s(冷) | 主要可优化项 |
torch.compile(AOT 缓存命中) |
6 s(命中)/ 115 s(失效) | 改配置会使缓存失效 |
| KV pool + CUDA Graph + profile | 105~254 s | 图不可省;profile 有 ±2 GiB 抖动 |
其中 Model loading took 内部又包含一次重复读取:MTP 草稿头(model.layers.78.*)走同一条 NFS 路径再读一遍,但因第一遍已把 390 GB 拉进页缓存,只花 10~38 s,可忽略。
1.2 耗时的两个决定变量
把历史 47 次 Model loading took 排一遍,决定它的只有两件事:
- 页缓存里还剩多少权重。
- NFS 当时有多忙(共享存储抖动)。
| 场景 | 耗时 | 说明 |
|---|---|---|
页缓存全热 + --enable-expert-parallel |
92 s | 不可作为 EP 收益,页缓存状态与冷读不可比 |
| 页缓存温(~60%) | 314~364 s | 多数日常重启落在此区间 |
| 页缓存热(99.8%),无 EP | 372~427 s | 必须全部走 NFS 读 + CPU 侧反序列化的下限 |
| 共享存储抖动 / 冷 | 711~920 s | 外部因素 |
| 页缓存仅 ~17% | 1802 s | 与同场景热 427 s 构成 4.2× 的直接对照 |
结论:375~430 s 是"必须全部从 NFS 读 + CPU 侧反序列化"的物理下限;92 s 是页缓存全热时的上限附近。两者之间的差值全部来自页缓存命中率。
1.3 CPU 侧反序列化不是瓶颈
- 权重从 NFS 读入后,vLLM 加载器在 CPU 侧做 safetensors 反序列化、W4A16 权重的解包 / 反量化(dequantize)与 tensor 构造。实测这一步 25 个分片合计约 1~2 min,远低于 NFS 读取时间,不是加载瓶颈。
2. 页缓存:决定 7 分钟还是 30 分钟
2.1 热与冷是两种完全不同的负载状态
| 状态 | CPU 利用率 | 磁盘读 | 有效速率 |
|---|---|---|---|
| 热(上次运行读过) | 高(瓶颈转移到 CPU) | 少量回源 | 快 |
| 冷(驱逐后) | 极低(几乎空转等 I/O) | 全部走 NFS | 0.4~3.5 GB/min |
冷缓存下 CPU 几乎不工作,全部时间在等 NFS 返回数据。因此:
- 任何两次启动的性能对比,必须先统一页缓存状态,否则数据不可比。
- 正式压测或性能基准从冷缓存开始,热缓存数据只用于验证"最好情况"。
2.2 预读机制
vLLM 自身已内建预读(weight_utils.py,日志行 Prefetching checkpoint files into page cache finished in X s):网络盘上走 safetensors_load_strategy="prefetch",8 线程 × 16 MiB 块,分片按 rank 轮分。
本套件在此基础上加了一步更早开始 :scripts/prefetch_weights.py 在 docker run 之前发非阻塞的 posix_fadvise(WILLNEED),利用 worker 启动前约 90~150 s 的空窗预填页缓存。
配套的 check 子命令用 mincore 精确统计页缓存命中率(390 GiB 约 24 s,不占 GPU),该数字能直接预测本次启动耗时 。缺省 WARM_PAGECACHE=1:命中率 ≥ 0.60 就跳过预读,省 NFS 带宽。
外部预读的增量收益未独立实测(两次真跑页缓存都已是 99.8%,预读正确跳过)。要测"冷 + 预读"那一格需先制造冷缓存(需要 root)。
2.3 为什么不做"串行预读再启动"
- 预读和加载读的是同一份 419 GB。如果先显式预读完再启动,启动时页缓存已经热,确实快;但总时间 = 预读时间 + 启动时间 ,等于把同一次 NFS 传输拆成了两段串行,总时间不变还多了一次系统调用开销。正确做法是让预读与 worker 启动并行(空窗期预读),而不是串行。
3. 磁盘规划
3.1 原则
- 权重目录只读挂载进容器 (
-v $MODEL_HOST_DIR:/models:ro),防止运行时意外写入。 - I/O 路径的判断先测再定。在这台机器上共享盘顺序读写明显快于某些本地盘。
- 元数据要一起测。共享文件系统上文件数往往比字节数更关键。创建 / stat / 删除是元数据操作,吞吐远低于大块读写。
3.2 磁盘速度测试
bash
D=<目标目录>
# 顺序写(direct,避开页缓存)
dd if=/dev/zero of=$D/ddtest bs=8M count=1024 oflag=direct
# 顺序读(同一文件 + direct)
dd if=$D/ddtest of=/dev/null bs=8M iflag=direct
# 4K 随机读(评估小 I/O 延迟,影响同机其他人)
fio --name=rand4k --ioengine=libaio --direct=1 --bs=4k \
--rw=randread --filename=$D/ddtest --numjobs=1 --runtime=15
3.3 部署期磁盘布局
| 用途 | 位置 | 原因 |
|---|---|---|
| 权重(只读) | NFS / 共享存储 | 419 GB / 25 分片,多机复用同一份 |
| 工程 / 脚本 / 日志 | 本地盘 | 小文件,随代码走 |
/dev/shm(容器内) |
tmpfs | NCCL 通信 + 权重暂存,按需分配 |
- 部署磁盘规划的核心只有一条:确保权重目录的 NFS 链路带宽稳定,且加载期间不被其他大 I/O 抢占。
4. 内存护栏
4.1 容器 /dev/shm
- Docker 默认
/dev/shm只有 64 MB(或 1 GB),不足以承载 8 卡 NCCL 通信与权重暂存。启动时显式指定:
bash
docker run --shm-size=8g ...
tmpfs 按需分配,--shm-size=8g 是上限而非预占,空时仅占 16 KB。
4.2 cgroup 内存护栏:两个设置配套
- 防止容器内存涨满触发全局 OOM(波及同机其他用户),需要两条配置同时生效:
| 设置 | 作用 | 只设另一条的后果 |
|---|---|---|
MemoryMax=<N> |
硬墙,进程内存不超过 N | 默认 oom.group=0,OOM 只杀 1 个进程,其余 7 个 rank 卡在 NCCL 上(PG 超时 12 h)→ 白等 |
memory.oom.group=1 |
触墙时整个 cgroup 一起杀 | 没有墙,照样全局 OOM |
systemd 属性写法注意:
MemoryOOMGroup=yes在 systemd < 253 上不存在,会整条命令回滚 。正确做法:先systemctl set-property <unit> MemoryMax=...,再echo 1 > /sys/fs/cgroup/<unit>/memory.oom.group。- 任何
set-property之后必须重设memory.oom.group并复核io.max(set-property是一次全量资源属性重下发,会覆盖手工热改值)。
取值依据 :留足系统 + 同机其他人的余量。memory.current 贴在 MemoryMax 附近是正常现象(内核拿可回收页缓存填满 cgroup),真正决定生死的是不可回收部分(anon + shmem + pagetables)。监视脚本应看距墙余量而非 shm 绝对值。
4.3 OOM 善后
进程被杀后,tmpfs 上的文件不会自动释放。必须确认无进程持有后手动清理,否则残留数据持续占用内存,影响后续启动。
bash
find /dev/shm -maxdepth 1 -name 'torch_*' -print0 | xargs -0 -r rm -f
5. I/O 纪律与判活
5.1 加载期间不要抢 I/O
- 不要在权重加载时执行大批量文件删除(会与 NFS 读抢带宽,两边都变慢)。
- 不要在加载时做全目录扫描或大量元数据操作。
- 与别人共用一块盘时,加载期的突发 I/O 会变成别人的延迟尖峰。阈值按 §3.2 在你自己的设备上实测后定。
5.2 判活三件套
NFS 慢看起来跟死了一样,D 态 ≠ 卡死。判据:
日志大小 + CPU 时间 +
/proc/<pid>/io三者同时停滞 ≥ 20 min,才判定挂起。
只看日志或只看 CPU 都会误判。误判的代价是重启------丢掉已跑的时间。最可靠的辅助信号是栈位置变化(py-spy dump)。
5.3 /proc/<pid>/io 的读法
read_bytes:可用于证明谁在实际读数据(部署期 8 rank 应各读自己那份)。write_bytes:不计页缓存回写 。进程写进 page cache 就返回,回写由内核完成、不计在进程头上。它可以是 0 而数据确实在落盘,不能据此判定"没在写"。df用量增长:共享盘上不能直接归因给自己,别人的任务也在写。
6. 踩坑与证否
-
"加载慢是 CPU 的问题"------部署期不成立。 实测 CPU 侧反序列化仅占 1~2 min,占总耗时的不到 5%。真因是 NFS 带宽 × 页缓存命中率。教训:先隔离测量再归因,不要凭直觉。
-
"网络盘一定慢,排除共享盘"------实测推翻。 这台机器上共享盘顺序读 0.72 GB/s、写与本地盘相当。教训:I/O 路径判断一律先测(§3.2),且结论是逐机器的,不能照抄。
-
"92 s 启动说明 EP 让加载快了 4 倍"------不可比。 那次 92 s 页缓存已全热,与 372 s 的冷读页缓存状态不同,是两件事。EP 本身还有额外启动代价(JIT +55 s,编译缓存 key 变化)。教训:两个变量同时变了就不能归因。
-
拿不同口径的
dd读数当"盘够用"的依据。 块大小、direct/buffered、O_DIRECT 参数不同,读数不可并列。教训:口径不一致的读数不并列,宁可标"存疑"。 -
用
systemd-run --user限速,参数被接受但静默忽略。user.slice未委派 io 控制器,io.max根本没写进内核。教训:只信--selftest的实测数字,不信任"命令没报错"。 -
17.6 张量/秒是页缓存加持下的数字,不是常态。 冷缓存只有 0.4~3.5 GB/min。教训:不把某次热缓存下的速率当预期值。
7. 复现命令
bash
# ══ 页缓存检查与预读 ══
./warm_cache.sh check # mincore 统计命中率
./deploy.sh prewarm dry-run # 列出 25 个分片与总量
./deploy.sh prewarm run --if-cold # 冷时预读(热则自动跳过)
WARM_PAGECACHE=0 ./deploy.sh start # 制造"冷 + 不预读"对照
# ══ 启动与日志 ══
./deploy.sh start
grep -h "Model loading took" logs/vllm-*.log | tail -20
grep -h "Prefetching checkpoint files into page cache finished" logs/vllm-*.log | tail -5
docker logs glm53-cybersec-w4a16 2>&1 | grep -m1 "init engine"
# ══ 存储与内存 ══
findmnt -no FSTYPE,SOURCE,TARGET "$(dirname "$(readlink -f models/GLM-5.3-CYBERSECURITY-W4A16)")"
df -h /dev/shm; free -g | head -2
# ══ cgroup 护栏复核 ══
CG=/sys/fs/cgroup/system.slice/glm53-cybersec.service
cat $CG/memory.max; echo
cat $CG/memory.oom.group; echo
# OOM 后的 tmpfs 残留检查
find /dev/shm -maxdepth 1 -name 'torch_*' | wc -l