FFmpegの並列処理化でCPUマルチスレッドがNVENCに迫る衝撃

FFmpegの並列処理化でCPUマルチスレッドがNVENCに迫る衝撃 HowTo

導入 ― 並列処理の衝撃

パソコンのCPU、最近コアばっかり増えてるけど正直もてあましてない?
これ、僕の本音です。Core i7-8700 なんて6コア12スレッドあるのに、普段は数スレッドしか動いてない。
「じゃあ残りの10スレッドは何してるの?」と問い詰めたくなるわけです。

ところが今回、PowerShell 7 の ForEach-Object -Parallel を使ってみて、
眠っていたCPUが一斉に目を覚ました瞬間を体験しました。

やったことは単純。
FFmpegで「静止画+音声 → 動画」を102本まとめて作るだけ。
普通なら1本ずつシングル処理で数分かかる作業です。

ところが並列処理をかけた途端、処理速度が倍増
さらにGPUのNVENCを蹴ったら、目を疑うようなスループットが出ました。

「GPUが最強」って思い込んでたんですが、
実はCPU並列化だけでもかなりいい勝負をするんですよ。
この気づきが今回の記事のテーマです。

実験環境と準備

まずは舞台をそろえましょう。
今回の「CPU並列 vs GPU NVENC」対決に使った環境はこちらです。

  • CPU: Intel Core i7-8700(6コア12スレッド)
  • GPU: NVIDIA RTX 3060 / VRAM12GB(NVENC対応)
  • メモリ: 32GB
  • ストレージ: NVMe SSD
  • OS: Windows 11 Pro
  • ツール: PowerShell 7.5.2 / ffmpeg version N-111584-ga4e616824b-20230722

素材はシンプルです。

  • サムネイル画像(thumbs\*.png
  • 音声ファイル(audio\*.mp3
  • 出力は短尺動画(videos\*.mp4

処理の流れも明快。

  1. サムネと音声をペアにする
  2. FFmpegで動画化する
  3. 102本まとめて変換する

スクリプトのポイント

普通に ForEach-Object で回せば、1本ずつ順次処理。
でも PowerShell 7 の ForEach-Object -Parallel に書き換えると、
CPUのコア数に応じて複数のFFmpegが同時に走り出します。

しかもオプションで -UseNvenc を指定すれば、
エンコード処理をGPUに任せることもできる。

「Jobs=1 → CPUシングル」
「Jobs=6 → CPU並列」
「Jobs=6 + UseNvenc → GPUエンコ」
という三つ巴の対決構造がこれで整いました。

実測ベンチマーク

いよいよ計測です。
素材はサムネ画像と音声ファイル、それぞれ102本。
これを短尺動画にまとめる作業を、条件を変えて3パターンで比較しました。

1. シングルCPU(Jobs=1)

PowerShellを普通に回した場合、1本ずつ順番に処理します。
結果は 5分10秒、19.7本/分
「まぁこんなものだろう」という妥当な速度です。

2. CPU並列(Jobs=6)

ここで ForEach-Object -Parallel を投入。
CPUの6コアを総動員した結果、2分37秒、38.9本/分
ちょうどシングルの2倍近いスループットが出ました。
「眠っていたコアが一斉に目を覚ました」瞬間です。

3. GPU NVENC(Jobs=6 + -UseNvenc)

さらにオプションでNVENCを有効化。
結果は 2分02秒、49.9本/分
GPUの専用エンジンを叩いた分、もうひと伸びしました。


結果を並べると

  • シングルCPU (Jobs=1)19.7 本/分
  • CPU並列 (Jobs=6)38.9 本/分
  • CPU並列 + NVENC (Jobs=6)49.9 本/分

グラフにすれば一目瞭然、シングル → 並列 → GPU と三段ロケットのように加速していきます。

考察 ― CPU並列はGPUに迫れるか

結果を眺めてみると、まず目を引くのは CPU並列だけでシングルのほぼ2倍 に跳ね上がったことです。
PowerShell 7 の ForEach-Object -Parallel は、記述をわずかに変えるだけでCPUコアを総動員してくれます。
普段は眠っているスレッドが一斉に働き出す様子は、まさに“工場をフル操業”させたような爽快感がありました。

さらにGPUのNVENCを使えば処理速度は約1.3倍に伸びました。
「やはりGPUは速い」という常識は裏切りません。
ただ注目すべきは、CPU並列がNVENCの背中をしっかり捉えている点です。
かつて「動画エンコードはGPU一択」という固定観念がありましたが、
実際にはCPU並列も十分に戦える力を持っているのです。

もちろん、NVENCはハードウェア専用エンジンゆえに高速ですが、画質やエンコード設定の自由度は限られます。
対してCPUエンコードは柔軟に調整できる反面、速度が課題でした。
並列化はその弱点を大きく補い、「高画質を狙いたいからCPUで」といった選択を現実的にしてくれます。

そして見逃せないのは、NVENC非搭載のノートPCでも並列化の恩恵が大きいことです。
最新のモバイルCPUはコア数が増えていますが、GPUは心もとない場合が多い。
そうした環境でも「CPU並列化」という選択肢は、実効性能を一気に引き上げてくれます。

まとめ ― ハードをしゃぶり尽くす精神

・あなたのハードウェアは、まだ眠っている。
・今あるリソースを叩き起こし、限界まで使い切れ

今回の並列処理で私が味わったのは、マシンをフル回転させたときにだけ訪れる、あの恍惚感でした。

スティーブ・ウォズニアックはこんな趣旨の言葉を残しています。
僕はハードの進化が止まるのを待ち望んでいる。
そのときこそ、本当にソフトウェアの勝負が始まるからだ。

Jobsが幻想を売り歩いたとするなら、ウォズは現実を極限まで叩き起こすエンジニアでした。
そしてそのスピリットは、今の私たちにも生きています。

CPU並列もGPU NVENCも、どちらかを選ぶ必要はありません。
両方を併用してリソースをしゃぶり尽くすことで、眠っていた力を呼び覚ますことができるのです。

固定観念に縛られる必要はない。
ハードを限界まで使い切るかどうかは、あなた次第です。

スティーブ・ウォズニアックに、静かなる敬意と御冥福を。

付録: 実験に使用したスクリプト

# PowerShell 7 並列版 make_videos_parallel.ps1
# 以下のコードは MIT ライセンスの下で公開します。
# ご自由に利用・改変・再配布して構いませんが、無保証です。
#
# make_videos_parallel.ps1
# FFmpeg を並列実行して「音声+サムネイル → 動画」をまとめて生成する
#
# Usage:
#   ./make_videos_parallel.ps1 [-ThumbDir thumbs] [-AudioDir audio] [-OutDir videos]
#                              [-Jobs 6] [-UseNvenc] [-Ffmpeg ffmpeg]
#
# Options:
#   -ThumbDir   サムネイル画像のディレクトリ (default: thumbs)
#   -AudioDir   音声ファイルのディレクトリ (default: audio)
#   -OutDir     出力先ディレクトリ (default: videos)
#   -Jobs       並列実行数 (default: 論理コア数/2)
#   -UseNvenc   ハードウェアエンコードを利用 (h264_nvenc)
#   -Ffmpeg     FFmpeg 実行ファイルのパス (default: ffmpeg)
#
# Example:
#   ./make_videos_parallel.ps1 -Jobs 6 -UseNvenc

```powershell
# make_videos_parallel.ps1
# FFmpeg を並列実行して「音声+サムネイル → 動画」をまとめて生成する

param(
  [string]$ThumbDir = "thumbs",
  [string]$AudioDir = "audio",
  [string]$OutDir   = "videos",
  [int]$Jobs        = 0,
  [switch]$UseNvenc,
  [string]$Ffmpeg   = "ffmpeg"
)

function Normalize-Jp([string]$s){
  if ([string]::IsNullOrEmpty($s)) { return $s }
  $s = $s.Normalize([Text.NormalizationForm]::FormKC)
  $s = $s -replace "[‐-–—−]", "-"
  $s = $s -replace "[·∙•・]", "・"
  return $s.Normalize([Text.NormalizationForm]::FormC)
}
function CanonKey([string]$s){
  $s = Normalize-Jp $s
  $s = $s.ToLowerInvariant()
  $s = ($s -replace "[\s\u3000_・\-()()[]【】/:.&&]", "")
  return $s
}

if ($Jobs -le 0) { $Jobs = [Math]::Max(1, [Environment]::ProcessorCount / 2) }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null

# サムネ一覧を「ゆるキー」で索引化
$thumbIndex = @{}
Get-ChildItem $ThumbDir -Filter *.png -File | ForEach-Object {
  $k = CanonKey $_.BaseName
  if ($k -and -not $thumbIndex.ContainsKey($k)) { $thumbIndex[$k] = $_.FullName }
}

$audios = Get-ChildItem $AudioDir -Filter *.mp3 -File | Sort-Object Name
$cnt = $audios.Count
if ($cnt -eq 0) { Write-Warning "no mp3 files in $AudioDir"; return }

$sw = [System.Diagnostics.Stopwatch]::StartNew()
$miss = [System.Collections.Concurrent.ConcurrentBag[string]]::new()

$audios | ForEach-Object -Parallel {
  $outDir  = $using:OutDir
  $ffmpeg  = $using:Ffmpeg
  $useNv   = [bool]$using:UseNvenc
  $index   = $using:thumbIndex
  $missBag = $using:miss

  function Normalize-Jp([string]$s){
    if ([string]::IsNullOrEmpty($s)) { return $s }
    $s = $s.Normalize([Text.NormalizationForm]::FormKC)
    $s = $s -replace "[‐-–—−]", "-"
    $s = $s -replace "[·∙•・]", "・"
    return $s.Normalize([Text.NormalizationForm]::FormC)
  }
  function CanonKey([string]$s){
    $s = Normalize-Jp $s
    $s = $s.ToLowerInvariant()
    $s = ($s -replace "[\s\u3000_・\-()()[]【】/:.&&]", "")
    return $s
  }
  function Find-Thumb($name){
    $key = CanonKey $name
    if ($index.ContainsKey($key)) { return $index[$key] }
    $hit = Get-ChildItem $using:ThumbDir -Filter *.png -File | Where-Object {
      (CanonKey $_.BaseName) -eq $key
    } | Select-Object -First 1
    if ($hit) { return $hit.FullName }
    return $null
  }

  $name  = $_.BaseName
  $thumb = Find-Thumb $name
  $audio = $_.FullName
  $out   = Join-Path $outDir ("$name.mp4")

  if (-not $thumb) { $missBag.Add($name); return }
  if (Test-Path $out) { return }

  if ($useNv) {
    $vArgs = @('-c:v','h264_nvenc','-preset','p5','-rc','vbr_hq','-cq','21',
               '-bf','2','-g','30','-spatial-aq','1','-temporal-aq','1',
               '-pix_fmt','yuv420p','-r','5','-movflags','+faststart')
  } else {
    $vArgs = @('-c:v','libx264','-preset','veryfast','-tune','stillimage','-crf','20',
               '-pix_fmt','yuv420p','-r','5')
  }
  $aArgs = @('-c:a','aac','-b:a','64k','-ac','1','-ar','44100',
             '-af','loudnorm=I=-16:TP=-1.5:LRA=11')

  $args = @('-y','-hide_banner','-nostats','-loglevel','error',
            '-loop','1','-framerate','2','-i',$thumb,'-i',$audio) + $vArgs + $aArgs + @('-shortest',$out)

  & $ffmpeg $args
  if ($LASTEXITCODE -ne 0) { $missBag.Add("$name (ffmpeg exit $LASTEXITCODE)") }
} -ThrottleLimit $Jobs

$sw.Stop()
$rate = [math]::Round($cnt / [Math]::Max($sw.Elapsed.TotalMinutes,1e-9),1)
Write-Host ("Total: {0:mm\:ss}  Items: {1}  Thruput: {2} videos/min  (Jobs={3})" -f $sw.Elapsed, $cnt, $rate, $Jobs)

if ($miss.Count -gt 0) {
  Write-Warning "Missed/Failed items:"
  $miss | Sort-Object -Unique | ForEach-Object { Write-Warning "  $_" }
}