我们有个"硬骨头"素材:单机拍摄的 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"
几个关键点:
-ss放在-i之前:这样是"先 seek 再解码",快,而且 ffmpeg 会重算时间戳(每段都从 0 开始);-threads 1:每段只用 1 线程(并行度靠进程数,不靠线程数);-an:每段不处理音频(音频单独做);xargs -P 8:8 路并行;- 参数完全一致:所有段用同一条命令模板,保证
-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
几个工程细节:
tempfile.mkdtemp+finally清理:分段文件很大(每段几百 MB),一定要清理;- 放 SSD:临时目录指定到 SSD(
tempfile.mkdtemp(dir='/mnt/ssd/tmp')); - 失败重试:单段失败(偶发)重试一次;
- 磁盘空间检查:8 段并行需要
源体积 × 0.3 × 8的临时空间(转码后每段约为源的 30%); - 进度:按"已完成段数 / 总段数"显示。
八、实测数据
素材: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 个编码进程,只会让上下文切换开销上升,总吞吐反而下降(前面分布式那篇的实测数据里也有这个结论)。
十、坑清单
- 切分点不在关键帧 → 段首花屏或者 ffmpeg 自动往前对齐导致段长度不对。
-ss放在-i后面 → 慢(要解码到那个位置),而且时间戳可能不从 0 开始。- 各段参数不一致 → 接缝处质量跳变。用同一条命令模板。
- 各段用了不同的 ffmpeg 版本(多机时)→ 同样的坑。统一镜像。
- 音频也跟着分段 → 接缝处咔哒声。音频单独整段处理。
- ABR 场景用切片并行 → 每段开头 VBV 重置导致码率尖峰。ABR 场景慎用。
- 段切得太碎 → IDR 太多,体积增加明显(32 段 +5%)。
- 机械盘上高并发 → IO 争抢,加速比大打折扣。用 SSD。
- 临时空间不够 → 8 段并行需要几倍于源的临时空间。先检查磁盘。
- 忘了清理临时分段文件 → 磁盘被占满(下一次任务就失败了)。用
finally。 - 拼接用了 concat 滤镜而不是 demuxer → 滤镜会重编码,前功尽弃。参数一致时用 demuxer +
-c copy。 - 段文件排序错了(
seg_10排在seg_2前面)→ 用零填充(%04d)保证字典序 = 时间序。 - 超过 CPU 核数的并行 → 吞吐下降。并行数 ≤ 核数。
- 没验证质量 → 一定要拿并行结果和串行结果做一次 VMAF 对比(至少抽一段)。
- VFR 源 → 切分吸附到关键帧后时长可能不精确。先转成 CFR。
最后说说这个方法给我最大的启发:当一个"本来就该并行"的任务并行不起来时,往往不是并行度不够,而是任务内部的依赖链太长。
视频编码的帧间依赖是本质性的(不能去掉,去掉就没有压缩率了)。但我们可以在更高的层级上切分——把"一个长依赖链"变成"N 个短依赖链",代价只是每个链的头多一个关键帧。
这个思路在很多地方都有:
- 数据库大批量导入 → 分批 commit(每批一个小事务,而不是一个长事务);
- 大文件哈希 → 分块哈希再合并(而不是整个文件一次算);
- 长视频转码 → 切片并行(本文)。
共同点是:接受一点点冗余开销(多一个关键帧 / 多一次提交 / 多一个分块标记),换取并行度的大幅提升。 这个交易在绝大多数情况下都很划算。
最后一个提醒:切片并行会让"单文件转码"这件事变复杂(切分、并行、拼接、清理、错误处理)。如果你的素材普遍不大(< 30 分钟),别上这套,直接用多进程一个文件一个任务就够了,简单可靠。只有在"单文件真的很大、等不起"的时候,才值得引入这套复杂度。