一个 returncode=-9 引发的 6 小时排查:长视频 OOM 从现象到根因

2026-06-04

凌晨 3 点,ntfy 弹了一条通知:cam-filter 流水线超时退出了。

打开日志,看到两行 returncode=-9。如果你在 Linux 上见过这个错误码,大概率跟我当时一样——第一反应是「内存爆了」。

但真正让我困惑的是后面的事:仓库里明明已经有一个「VAD 流式音频处理」的修复 commit,按理说不会再吃内存了。为什么 OOM 还在?

这篇文章记录的就是这次排查的全过程:从看到 returncode=-9 的困惑,到追到真正的根因,再到最终的降级方案。

什么是 cam-filter

cam-filter 是一个视频处理工具,核心功能是从家庭摄像头 24 小时录像中自动提取「有人/有声」的片段。工作流程:

  1. 用 Silero VAD(语音活动检测)找出有声音的时间段
  2. 用 YOLOv8 做画面中的人形检测
  3. 把 VAD 和 YOLO 的检测结果合并,提取最终的片段

跑在 CI/CD 流水线上,环境限制很明确:内存 4GB,超时 45 分钟。

在这个限制下处理 10-14 分钟的短视频,一切正常。但一旦视频时长拉到 97 分钟、分辨率到了 4K@48fps,事情就变了。

现象:两个视频的对照

失败的两个视频都是约 97 分钟的长视频,而成功的视频在 10-14 分钟之间。其他特征放在一起对比,差异就很清楚了:

特征 失败视频 成功视频
时长 ~97 分钟 10-14 分钟
分辨率 3840×2160 (4K) 3840×2160 (4K)
FPS 48fps 5-15fps
失败阶段 VAD 检测
信号 SIGKILL (-9)

帧率差异是关键。48fps 相比 5-15fps,意味着每秒要处理 3-10 倍的帧数,音频数据量也按比例膨胀。

排查:流式修复了,但没完全修

仓库里已经有一个 commit 93649d9,标题是「fix: VAD流式音频处理,避免长视频OOM」。代码逻辑看起来也对——用 block_size=16000*10 每次处理 10 秒音频(约 640KB)。

但 OOM 仍然发生了。

这说明流式处理不够——根因在别的地方。打开代码一看,发现了关键问题:

class VideoProcessor:
    def detect_speech(self, video_info):
        # ... 经过一系列处理,生成 WAV 文件 ...
        wav, sr = torchaudio.load(tmp_path)   # ← 全量加载 WAV 到内存
        wav = wav.to(self.device)             # ← 推送到设备内存
        # 后续才是流式 get_speech_timestamps()

这里有两个隐藏问题:

  1. torchaudio.load() 把整个 WAV 文件一次性读到内存。97 分钟的 4K 视频提取出来的 PCM 音频大约 5800 秒、约 92MB——本身不大,但 torchaudio.load() 的峰值内存远不止文件大小(涉及解码缓冲区、Tensor 创建等)。
  2. 加载完后还 wav.to(self.device),在 CPU 上这等于又多拷贝一份。

流式是 get_speech_timestamps() 内部的参数设置,但前面的 torchaudio.load() 已经把整个文件装进内存了。流式救不了全量读取。

另一个发现是分支分歧问题。image-builder 分支用 torchcodec 做音频解码,而 master 分支用 soundfile。两种库的流式行为不同,部分流式代码可能因为依赖版本不匹配而失败。

方案讨论:三个方向

发现根因后,摆在我们面前的有三条路:

方案 A:在调度层做两轮处理。 先只跑 VAD,如果失败再用 --skip-vad 跑 YOLO。改动中等但逻辑清楚。

方案 B:子进程隔离。 把 VAD 放到独立进程里,被杀也不影响主进程。改动大但彻底。

方案 C:--skip-vad 标志 + 自动降级重试。 给主脚本加一个跳过 VAD 的选项,CI 调度脚本检测到 returncode=-9 后自动用这个标志重跑。改动最小。

最终选了方案 C。理由很简单:长视频场景是偶发的(97 分钟的视频几天才出现一次),为它做大规模重构不划算。降级方案足够解决当前问题,长远优化可以慢慢来。

代码改动

代码改动分两部分。

主处理脚本 (cam_filter_v3.py):

class VideoProcessor:
    def __init__(self, ...):
        self.skip_vad = False

    def run(self, video_path, output_dir):
        if self.skip_vad:
            speech_intervals = []    # 跳过 VAD
        else:
            speech_intervals = self.detect_speech(video_info)
        # 后续 YOLO 检测...

CLI 参数加 --skip-vad 标志,main 块中赋值即可。

CI 调度脚本 (ci_process_assets.py):

result = subprocess.run(cmd, ...)
if result.returncode == -9:
    retry_cmd = ['python3', 'src/cam_filter_v3.py', '--skip-vad', ...]
    subprocess.Popen(retry_cmd, ...)

首次 VAD+YOLO 正常跑,失败(returncode=-9)自动重试为纯 YOLO 模式。重试输出追加到同一个日志文件。

降级后的 YOLO 会遍历所有视频帧,先用帧差法预筛,再对变化明显的帧做检测。数据处理量跟 VAD 不是一个量级,4GB 内存下跑得很稳。

最终方案:流式读取的正确姿势

降级方案虽然能兜底,但根因还是得修。正确的做法是把 torchaudio.load() 换成 soundfile 的流式读取:

import soundfile as sf
import torch

# ❌ 错误:全量加载到内存
wav, sr = torchaudio.load(tmp_path)

# ✅ 正确:流式逐块处理
with sf.SoundFile(tmp_path) as audio:
    for block in audio.blocks(blocksize=16000*10, dtype='float32'):
        speech_timestamps = get_speech_timestamps(
            torch.from_numpy(block), model, sampling_rate=16000
        )

这样每次只加载 10 秒音频(约 640KB),循环处理完整个文件。98 分钟的音频循环 580 次,总用时没有本质差别,但峰值内存从几百 MB 降到几 MB。

踩坑总结

# 踩坑点 原因 解决
1 VAD 流式修了但 OOM 仍在 torchaudio.load() 前置全量加载 改用 soundfile blocks 流式读取
2 4K@48fps 比预期多吃 3-10 倍内存 高帧率导致每帧数据量暴增 --skip-vad 降级方案兜底
3 ntfy 通知显示原始 JSON POST body 直接显示 改纯文本 + HTTP Header
4 summary.json 读不到 CI 上传为 Asset 后本地文件被清理 回退到 .ci_done 标记文件
5 git push 吞掉错误导致分支分歧 check=False 忽略了 push 失败 严格检查 git 操作的返回值

一点感悟

这次排查最有意思的地方,不是最终改了几行代码,而是「流式修复了但 OOM 还在」这个矛盾点。

看到代码里有一个流式处理的 commit,第一反应是「流式=解决了」。但流式是下游函数的参数设置,我跳过了上游的 torchaudio.load()——它根本没有流式的概念,一次性把整个 WAV 吞进内存。上游没解决,下游流式流的是已经装到肚子里的数据。

现在 --skip-vad 是兜底方案,让偶发的长视频处理能正常跑完。但长远来看,把 torchaudio.load() 彻底换成 soundfile 流式读取,才是更干净的解法。