Colibrì 加载模型为什么越来越慢 / 是磁盘冷层在拖后腿吗?

文章导读
同一个模型前几天加载还能接受,现在启动和首字都变慢,通常需要先把“加载慢”和“生成慢”拆开,再分别确认冷层回读和内存压力各占多少。Colibrì 这类带分层缓存的加载器,把冷层放在磁盘上就意味着每次未命中的层都要走一次读盘;但如果内存本身已经吃紧,换页和内存回收同样会把首字拖长。可行的判断顺序是:先用日志把加载耗时和首字耗时分开计时,再数一轮请求里冷层被回读了几次,然后把冷层目录换到更快的盘、跑同
📋 目录
  1. Ⅰ 在启动日志里分清是加载慢还是生成慢
  2. Ⅱ 数一遍一轮请求里冷层被回读了几次
  3. Ⅲ 把冷层目录换到更快的盘再跑同一批请求
  4. Ⅳ 内存真的不够时会出现哪些信号
A A

同一个模型前几天加载还能接受,现在启动和首字都变慢,通常需要先把“加载慢”和“生成慢”拆开,再分别确认冷层回读和内存压力各占多少。Colibrì 这类带分层缓存的加载器,把冷层放在磁盘上就意味着每次未命中的层都要走一次读盘;但如果内存本身已经吃紧,换页和内存回收同样会把首字拖长。可行的判断顺序是:先用日志把加载耗时和首字耗时分开计时,再数一轮请求里冷层被回读了几次,然后把冷层目录换到更快的盘、跑同一批输入做对照,最后用内存信号确认是不是另一条线索在起作用。

判断顺序建议固定为:日志分阶段计时 → 统计一轮请求的冷层回读次数 → 换盘复跑同一批输入 → 观察常驻内存与换页信号。只有回读次数明显偏高、且换盘后延迟跟着变化,才能把责任归到磁盘冷层;若回读次数不高而 majflt、swap 在涨,优先按内存不足处理。换盘属于介质层面的缓解手段,不减少回读次数,也不能替代对分层阈值和并发配置的调整,具体字段与日志格式需要结合本机版本确认。

在启动日志里分清是加载慢还是生成慢

把这两段混在一起看,很容易得出“模型加载变慢”的错误印象——实际上进程可能早就加载完了,慢在首字计算。加载阶段的日志通常从引擎进程启动行开始(带 version、config loaded 一类字段),到出现 model loaded、weights ready、shards mmap done 这类行结束;生成阶段的日志则从请求进入开始(request id、prefill start 或 decode start),到 first token / first token emitted,再到本轮 decode 结束。有些实现把首字算在 prefill 里,所以首字时间要单独取,不要看成“加载完成”那一刻。

最稳妥的做法是分两次测:先只启动、不发任何请求,量“进程启动 → 加载完成”这一段;再固定一条输入,量“请求发出 → 首个 token 打印”这一段。日志级别偏低时可能根本没有 first token 行,需要临时调到 info 或 debug,验证完再调回,否则会带来额外的日志开销。

# 只测加载段:脚本名、日志路径、匹配关键词按实际替换
start=$(date +%s.%N)
./start-colibri.sh >> run.log 2>&1 &
while ! grep -qE 'model loaded|weights ready' run.log; do sleep 0.2; done
end=$(date +%s.%N)
echo "load_seconds=$(echo "$end - $start" | bc)"

# 把“加载完成”和“首字到达”两条日志的时间戳直接相减,也能得到首字延迟
awk '/model loaded|weights ready/ {print "loaded", $1, $2}
     /first token/                  {print "first_token", $1, $2}' run.log

数一遍一轮请求里冷层被回读了几次

确认慢是否来自冷层反复回读磁盘,关键是拿到“一轮请求读了多少盘”的可比较读数。系统级可以看 pidstat -d 或 iostat -x 1 里目标盘的 r/s 与 rkB/s;进程级更直接,取 /proc/<pid>/io 的 read_bytes 前后差值;引擎自身日志里通常也会打冷层命中、未命中、回填一类关键词(cold、spill、offload、cache miss,具体拼写以本机日志为准),两者可以交叉验证。

pid=$(pgrep -f colibri | head -1)
before=$(awk '/read_bytes/{print $2}' /proc/$pid/io)
# 这里发一轮固定输入(同样的 prompt、batch、max tokens)
after=$(awk '/read_bytes/{print $2}' /proc/$pid/io)
echo "read_bytes_delta=$((after-before))"

# 同时看目标盘有没有持续读
iostat -x 1 5

read_bytes 包含进程的所有读,不止冷层,所以要用日志里的命中计数做校正。什么样的读数算异常:首轮请求读盘多属于正常;连续几轮相同输入仍维持相近的回读量,说明冷层没被缓存住或被别的请求挤出去了;一轮请求的回读字节明显超过这些层本身的大小,说明存在重复回读。绝对值没有通用标准,需要结合模型层大小、常驻内存预算和并发数一起看,单看一个数字容易误判。

把冷层目录换到更快的盘再跑同一批请求

冷层目录的位置一般在引擎配置里指定,字段名常见为 cold_dir、offload_path、cache_path 这类写法,也可能是环境变量或启动参数。先确认它到底指向哪块盘,再决定换不换:

grep -rnE 'cold|offload|spill|cache_dir' /etc/colibri/ ~/.config/colibri/ 2>/dev/null
findmnt -T /path/to/cold_layer_dir
df -h /path/to/cold_layer_dir

更换步骤建议按这个顺序:停服务 → rsync -a 拷贝目录并保留权限 → 改配置指向新挂载点 → 确认挂载与属主正确 → 启动 → 跑同一批固定输入(同样的 prompt、batch、并发、max tokens)→ 记录读盘次数和首字延迟。两轮之间只改盘,不改任何其他参数,否则对照就失去意义。下面这张记录表可以直接照着填:

场景一轮请求回读次数(read_bytes 差值或日志计数)首字延迟备注
原盘(如 HDD 或网络盘)待填待填同一批输入,先跑两轮看是否稳定
换盘后(如本地 NVMe)待填待填配置只改冷层目录

风险边界要提前说清:换盘只改变回读的介质速度,不减少回读次数。如果换盘后回读次数没变、首字延迟却明显改善,说明瓶颈在介质;如果换盘后两者都没动,就别再在盘上花时间,回去看分层阈值、常驻内存和并发配置。另外网络盘换本地盘的差别通常比本地 SATA SSD 换 NVMe 更明显,中等差异需要靠记录表判断,不能凭感觉。

内存真的不够时会出现哪些信号

内存不足和冷层回读都会表现为“首字变慢”,但可观察信号不同。内存这一侧的看点包括:常驻内存峰值 VmHWM 与当前 VmRSS、进程主缺页次数 majflt、系统级 vmstat 的 si/so、进程 VmSwap,以及 cgroup 环境下 memory.stat 的 pgmajfault 和 memory.events 里的 high、max 计数。dmesg 中出现 oom 记录则说明已经发生过回收失败。

grep -E 'VmRSS|VmHWM|VmSwap' /proc/$pid/status
awk '{print "minflt="$10, "majflt="$12}' /proc/$pid/stat
vmstat 1 5

怎么区分:RSS 贴近容量上限、majflt 持续增长、si/so 不为零,同时进程级 read_bytes 并不高——这条线索指向内存不足;反过来,RSS 平稳、majflt 基本不动,read_bytes 随请求轮次同步起伏——指向冷层回读。两条线索同时出现也很常见,此时先解决内存,再看剩余回读量还有多少。

确认是内存问题后,优先调整方向是降低并发与 batch、缩小除常驻层以外的额外缓存占用、检查是否有其他进程在同一台机器上争内存;swap 只能缓冲,不能当成性能方案,把它写进优化结论会让下次排查更乱。需要结合环境确认容器的 memory limit 是否卡在配额上,宿主机看着有空闲内存并不代表容器内够用。