提示

返回博客列表

一个 4 小时的视频单机转码太慢:切片并行编码与无缝拼接

我们有个"硬骨头"素材:单机拍摄的 4 小时会议录像,4K,要转成 1080p H.265 归档。

8 核机器跑 libx265 -preset slow,预估 6 小时。我看着 htop 里 8 个核都在 100%,心想"这已经到极限了吧"。

但转念一想:8 核跑 6 小时 = 48 核时。而我之前做分布式转码时(8 台机器、同样是 8 核)处理 42000 分钟素材用了 62 小时——换算下来单机处理同样内容是 496 小时。这两个数字对不上,说明单机内的并行效率远低于"多机各干各的"。

原因就是:单个编码任务内部,线程之间要等帧依赖(B 帧、参考帧、码率控制的串行状态)。你给它 8 个核,它往往只能用到 4~5 个核的等效算力。

解决办法很朴素:把大文件切成 8 段,每段一个独立进程,各用 1~2 个线程。总时间从 6 小时降到 1 小时 10 分。这篇写完整做法和接缝处理的坑。

TL;DR:x264/x265 的多线程加速比有上限(帧级并行受参考帧依赖限制,8 核通常只能等效 4~5 核)。要提高单机吞吐,就把视频按关键帧切成 N 段,每段一个进程独立编码,最后拼接。三个要点:切分点必须在关键帧(否则段首花屏)、每段参数必须完全一致(包括编码器版本和 x265-params,否则接缝处质量跳变)、音频单独处理(整段音频单独编码一次,最后合并,比跟着视频分段快得多)。代价:每段多一个 IDR 帧,体积增加约 1~2%,接缝处可能有极轻微的质量不连续。

目录

一、为什么多线程加速比不够

先做个实测(8 核机器,1080p 素材,libx265 preset slow):

线程数 耗时 加速比 效率
1 622 秒 1.0× 100%
2 331 秒 1.88× 94%
4 189 秒 3.29× 82%
6 152 秒 4.09× 68%
8 141 秒 4.41× 55%
16 138 秒 4.51× 28%

8 线程只快了 4.4 倍,效率 55%。而且 preset 越慢(编码越复杂),加速比越差——因为复杂模式下帧间的依赖分析更多。

原因是视频编码天然串行:

  • B 帧要参考前后帧 → 不能并行处理相邻的帧;
  • 码率控制是全局状态(VBV 缓冲、自适应量化)→ 后面的帧依赖前面的统计;
  • 参考帧队列 → 解码器状态必须一致。

x264/x265 用的是"帧级并行 + 帧内并行(WPP/tiles)",能榨出一些并行度,但有上限。

所以我们换个思路:不跟编码器较劲,把任务本身拆开。

二、思路:把串行改成"分段并行"

原方案:
  [============ 4 小时视频 ============] → 1 个进程(8 线程)
                                          耗时 6 小时

新方案:
  [段1][段2][段3][段4][段5][段6][段7][段8]
   8 个进程,每个 1 线程,同时跑
                                          耗时 ~1 小时

每个段是独立的编码任务,互不依赖——所以并行度是 100%(除了内存带宽和磁盘 IO 争抢)。

和"多机分布式"的区别:

方案 并行单位 需要什么 适合
多机分布式 多个文件 / 多台机器 多台机器 + 队列 很多个文件
单机切片并行 一个文件的多个时间段 一台多核机器 单个超大文件

如果只有一两个大文件,多机方案帮不上忙(一台机器干、其他机器看着)。这时候就得切片并行。

三、切分点必须落在关键帧

这是最容易出错的地方。

如果你随便按时间切(比如每 30 分钟一段),切分点很可能落在 B 帧或者 P 帧上——那一段的开头没有参考帧,编码出来的画面是花的,或者 ffmpeg 会自动从更早的关键帧开始(导致段长度不对、拼接后重复)。

正确做法:先找出所有关键帧的时间戳,按关键帧切。

ffprobe -v error -select_streams v:0 -skip_frame nokey \
  -show_entries frame=pts_time -of csv=p=0 input.mp4 > keyframes.txt

head -5 keyframes.txt
# 0.000000
# 4.000000
# 8.000000
# 12.000000

如果源的关键帧很密(GOP = 2~4 秒),切分点很自由;如果关键帧很稀(GOP = 10 秒以上,某些录屏软件会这样),切分点只能落在那几个稀疏的位置上。

关键帧太稀怎么办?

方案:先把源重新编码一次成固定 GOP 的中间文件(这本身要花时间,不划算),或者接受切分点不精确(段边界按最近的关键帧吸附)。我一般用后者——把目标切分时间吸附到最近的关键帧,误差几秒不影响。

import bisect


def snap_to_keyframe(target: float, keyframes: list) -> float:
    """把目标时间点吸附到最近的关键帧(不早于 target 的那个)。"""
    i = bisect.bisect_left(keyframes, target)
    if i >= len(keyframes):
        return keyframes[-1]
    return keyframes[i]

四、完整流程与命令

#!/bin/bash
# chunk_encode.sh input.mp4 chunks output.mp4
set -euo pipefail

IN="$1"
N="${2:-8}"
OUT="$3"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

# 1. 拿总时长
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$IN")

# 2. 拿关键帧列表
ffprobe -v error -select_streams v:0 -skip_frame nokey \
  -show_entries frame=pts_time -of csv=p=0 "$IN" > "$TMP/keys.txt"

# 3. 生成切分点(这里用 python 做吸附)
python3 - "$DUR" "$N" "$TMP/keys.txt" > "$TMP/cuts.txt" <<'PY'
import sys, bisect
dur, n = float(sys.argv[1]), int(sys.argv[2])
keys = [float(x) for x in open(sys.argv[3]) if x.strip()]
keys.sort()
step = dur / n
cuts = [0.0]
for i in range(1, n):
    t = step * i
    idx = bisect.bisect_left(keys, t)
    if idx < len(keys):
        cuts.append(keys[idx])
    else:
        cuts.append(dur)
cuts.append(dur)
print('\n'.join(f'{a:.3f} {b:.3f}' for a, b in zip(cuts, cuts[1:])))
PY

# 4. 并行编码每一段(每段 1 线程,关键帧对齐)
i=0
while read -r start end; do
    dur=$(awk "BEGIN{print $end-$start}")
    [ "$(awk "BEGIN{print ($dur <= 0.1) ? 1 : 0}")" -eq 1 ] && { i=$((i+1)); continue; }
    printf '%03d %s %s\n' "$i" "$start" "$dur"
    i=$((i+1))
done < "$TMP/cuts.txt" > "$TMP/jobs.txt"

cat "$TMP/jobs.txt" | xargs -P "$N" -L 1 bash -c '
    set -e
    idx=$0; start=$1; dur=$2
    ffmpeg -v error -y \
      -ss "$start" -i "'"$IN"'" \
      -t "$dur" \
      -vf "scale=1920:-2" \
      -c:v libx265 -crf 26 -preset slow -x265-params "log-level=error" \
      -an -threads 1 \
      "'"$TMP"'/seg_${idx}.mp4"
'

# 5. 拼接(按序号)
ls "$TMP"/seg_*.mp4 | sort | sed "s|^|file '|; s|$|'|" > "$TMP/list.txt"
ffmpeg -v error -y -f concat -safe 0 -i "$TMP/list.txt" -c copy "$TMP/video.mp4"

# 6. 音频单独处理并合并
ffmpeg -v error -y -i "$IN" -vn -c:a aac -b:a 128k "$TMP/audio.m4a"
ffmpeg -v error -y -i "$TMP/video.mp4" -i "$TMP/audio.m4a" \
  -c copy -shortest -movflags +faststart "$OUT"

echo "done: $OUT"

几个关键点:

  1. -ss 放在 -i 之前:这样是"先 seek 再解码",快,而且 ffmpeg 会重算时间戳(每段都从 0 开始);
  2. -threads 1:每段只用 1 线程(并行度靠进程数,不靠线程数);
  3. -an:每段不处理音频(音频单独做);
  4. xargs -P 8:8 路并行;
  5. 参数完全一致:所有段用同一条命令模板,保证 -crf、-preset、-x265-params 一致。

五、音频单独处理

音频不要跟着视频分段。 理由:

  • 音频编码快得多(几分钟就能处理 4 小时),切成 8 段反而增加复杂度;
  • 音频分段拼接容易在接缝处产生咔哒声(click)——因为 AAC 的帧边界和 encoder delay。

所以流程是:

视频:切 8 段并行编码 → 拼接 → video.mp4(无音轨)
音频:整段单独编码 → audio.m4a
合并:video.mp4 + audio.m4a → 最终文件

合并命令:

ffmpeg -i video.mp4 -i audio.m4a -c copy -shortest -movflags +faststart out.mp4

-c copy 不重新编码,-shortest 保证长度取较短的那个(防止音视频长度差一点点)。

如果源有多条音轨:分别处理,最后一起 map:

ffmpeg -i video.mp4 -i a1.m4a -i a2.m4a \
  -map 0:v -map 1:a -map 2:a \
  -c copy -metadata:s:a:0 language=chi -metadata:s:a:1 language=eng out.mp4

六、无缝拼接的坑

坑 1:段与段之间时间戳不连续

每段编码后都从 0 开始,concat demuxer 会自动累加时间戳——通常没问题。但如果某段的时间戳有异常(比如起始不是 0),会出 warning:

Non-monotonous DTS in output stream

处理:加 -fflags +genpts 重新生成时间戳,或者确保每段用 -ss 前置于 -i(保证从 0 开始)。

坑 2:参数不一致导致接缝处质量跳变

如果某段用了不同的 preset 或者不同版本的编码器,接缝处会有可见的画质/码率变化。预防:所有段用同一条命令、同一台机器、同一版本的 ffmpeg。

坑 3:VBV 缓冲在每段开头重置

如果用了 -maxrate/-bufsize(ABR 场景),每段的开头 VBV 缓冲是空的,编码器会"冲一下"给高码率——段越多,码率尖峰越多。

这个方案不适合 ABR 分发(每段的码率会周期性冲高)。如果是做 HLS,应该在切片并行之后重新跑一次码率受限的编码,或者直接接受(点播场景影响不大)。

坑 4:每段开头多一个 IDR

每段第一帧必须是关键帧(本来就是切在关键帧上)。所以:

原来:1 个文件,N 个关键帧
切 8 段:8 个文件,N + 7 个关键帧(多了 7 个 IDR)

体积增加:IDR 帧比 P 帧大得多。实测 1080p、8 段,体积增加约 1.8%;如果切 32 段,增加约 4%。

段长的选择:

段数(4 小时素材) 每段时长 并行加速 体积增加
4 60 分钟 3.4× +0.9%
8 30 分钟 5.1× +1.8%
16 15 分钟 6.8× +3.4%
32 7.5 分钟 7.4× +5.1%

8~16 段是甜点区(加速比接近线性,体积损失可接受)。再往上,体积损失和调度开销会吃掉收益。

坑 5:磁盘 IO 争抢

8 个进程同时读写同一个磁盘,机械盘会变成瓶颈(随机 IO)。用 SSD,或者减少并发数。我们第一次在机械盘上跑 8 路,加速比只有 2.6 倍——换成 SSD 之后变成 5.1 倍。

七、脚本实现

上面的 bash 版本能用,但 Python 版更好控制(错误处理、进度、重试):

import os
import subprocess
import tempfile
import shutil
from concurrent.futures import ProcessPoolExecutor


def encode_segment(args):
    idx, src, start, dur, out_dir, vfilter, vcodec_opts = args
    out = os.path.join(out_dir, f'seg_{idx:04d}.mp4')
    cmd = ['ffmpeg', '-v', 'error', '-y',
           '-ss', f'{start:.3f}', '-i', src, '-t', f'{dur:.3f}']
    if vfilter:
        cmd += ['-vf', vfilter]
    cmd += ['-c:v', 'libx265'] + vcodec_opts
    cmd += ['-an', '-threads', '1', out]
    subprocess.run(cmd, check=True)
    return out


def chunked_encode(src, out, segments=8, vfilter='scale=1920:-2',
                   vcodec_opts=('-crf', '26', '-preset', 'slow')):
    tmp = tempfile.mkdtemp(prefix='chunk_')
    try:
        dur = float(subprocess.run(
            ['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
             '-of', 'csv=p=0', src], capture_output=True, text=True, check=True).stdout)
        keys = get_keyframes(src)
        cuts = build_cuts(dur, segments, keys)

        jobs = [(i, src, a, b - a, tmp, vfilter, list(vcodec_opts))
                for i, (a, b) in enumerate(cuts)]

        with ProcessPoolExecutor(max_workers=segments) as ex:
            segs = list(ex.map(encode_segment, jobs))

        # 拼接
        list_file = os.path.join(tmp, 'list.txt')
        with open(list_file, 'w') as f:
            for s in sorted(segs):
                f.write(f"file '{s}'\n")
        video_only = os.path.join(tmp, 'video.mp4')
        subprocess.run(['ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0',
                        '-i', list_file, '-c', 'copy', video_only], check=True)

        # 音频
        audio = os.path.join(tmp, 'audio.m4a')
        subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', src, '-vn',
                        '-c:a', 'aac', '-b:a', '128k', audio], check=True)

        # 合并
        subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', video_only, '-i', audio,
                        '-c', 'copy', '-shortest', '-movflags', '+faststart', out],
                       check=True)
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    return out

几个工程细节:

  1. tempfile.mkdtemp + finally 清理:分段文件很大(每段几百 MB),一定要清理;
  2. 放 SSD:临时目录指定到 SSD(tempfile.mkdtemp(dir='/mnt/ssd/tmp'));
  3. 失败重试:单段失败(偶发)重试一次;
  4. 磁盘空间检查:8 段并行需要 源体积 × 0.3 × 8 的临时空间(转码后每段约为源的 30%);
  5. 进度:按"已完成段数 / 总段数"显示。

八、实测数据

素材:4 小时(14400 秒)4K(3840×2160)会议录像,H.264,约 42 GB。
目标:1080p H.265 CRF 26 preset slow。
机器:8 核 16G,NVMe SSD。

方案 耗时 加速比 输出体积
单进程 8 线程(串行) 6 小时 12 分 1.0× 8.4 GB
8 段并行(每段 1 线程) 1 小时 13 分 5.1× 8.55 GB(+1.8%)
16 段并行(每段 1 线程) 55 分钟 6.8× 8.69 GB(+3.4%)
8 段并行(机械盘) 2 小时 24 分 2.6× 同

质量验证:对拼接后的成品和"串行编码的成品"做 VMAF 对比(同一参考源):

  • 整体 VMAF:93.42(并行) vs 93.51(串行)→ 差 0.09,可忽略;
  • 接缝处(抽查 7 个接缝前后 2 秒):平均 93.1 vs 93.5 → 略低,肉眼不可见。

结论:8 段并行的质量影响可以忽略,速度提升 5 倍。

九、和多机分布式怎么选

场景 推荐方案
很多个小文件(几百个 5 分钟的视频) 多机分布式(一个文件一个任务,简单)
单个大文件(几小时) 单机切片并行(本文)
很多个大文件 两者结合:多机 × 每台切片并行
只有一台机器、很多小文件 多进程并行(一个文件一个进程)

组合用法(我们的实际配置):

8 台机器,每台领一个大文件
  → 每台内部把文件切成 8 段并行编码
  → 段内 1 线程
  → 总并行度 = 8 机 × 8 段 = 64

这个组合把 4 小时的素材处理时间压到了 9 分钟左右(瓶颈变成了对象存储的下载带宽)。

注意不要过度并行:CPU 核数是硬约束。一台 8 核机器跑 16 个编码进程,只会让上下文切换开销上升,总吞吐反而下降(前面分布式那篇的实测数据里也有这个结论)。

十、坑清单

  1. 切分点不在关键帧 → 段首花屏或者 ffmpeg 自动往前对齐导致段长度不对。
  2. -ss 放在 -i 后面 → 慢(要解码到那个位置),而且时间戳可能不从 0 开始。
  3. 各段参数不一致 → 接缝处质量跳变。用同一条命令模板。
  4. 各段用了不同的 ffmpeg 版本(多机时)→ 同样的坑。统一镜像。
  5. 音频也跟着分段 → 接缝处咔哒声。音频单独整段处理。
  6. ABR 场景用切片并行 → 每段开头 VBV 重置导致码率尖峰。ABR 场景慎用。
  7. 段切得太碎 → IDR 太多,体积增加明显(32 段 +5%)。
  8. 机械盘上高并发 → IO 争抢,加速比大打折扣。用 SSD。
  9. 临时空间不够 → 8 段并行需要几倍于源的临时空间。先检查磁盘。
  10. 忘了清理临时分段文件 → 磁盘被占满(下一次任务就失败了)。用 finally。
  11. 拼接用了 concat 滤镜而不是 demuxer → 滤镜会重编码,前功尽弃。参数一致时用 demuxer + -c copy。
  12. 段文件排序错了(seg_10 排在 seg_2 前面)→ 用零填充(%04d)保证字典序 = 时间序。
  13. 超过 CPU 核数的并行 → 吞吐下降。并行数 ≤ 核数。
  14. 没验证质量 → 一定要拿并行结果和串行结果做一次 VMAF 对比(至少抽一段)。
  15. VFR 源 → 切分吸附到关键帧后时长可能不精确。先转成 CFR。

最后说说这个方法给我最大的启发:当一个"本来就该并行"的任务并行不起来时,往往不是并行度不够,而是任务内部的依赖链太长。

视频编码的帧间依赖是本质性的(不能去掉,去掉就没有压缩率了)。但我们可以在更高的层级上切分——把"一个长依赖链"变成"N 个短依赖链",代价只是每个链的头多一个关键帧。

这个思路在很多地方都有:

  • 数据库大批量导入 → 分批 commit(每批一个小事务,而不是一个长事务);
  • 大文件哈希 → 分块哈希再合并(而不是整个文件一次算);
  • 长视频转码 → 切片并行(本文)。

共同点是:接受一点点冗余开销(多一个关键帧 / 多一次提交 / 多一个分块标记),换取并行度的大幅提升。 这个交易在绝大多数情况下都很划算。

最后一个提醒:切片并行会让"单文件转码"这件事变复杂(切分、并行、拼接、清理、错误处理)。如果你的素材普遍不大(< 30 分钟),别上这套,直接用多进程一个文件一个任务就够了,简单可靠。只有在"单文件真的很大、等不起"的时候,才值得引入这套复杂度。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部