導入 ― 並列処理の衝撃
「パソコンの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)
処理の流れも明快。
- サムネと音声をペアにする
- FFmpegで動画化する
- 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 " $_" }
}

