Chienomi

ゲーム動画の分散エンコーディングにさらなる力を mmfft9

開発::util

  • TOP
  • Articles
  • 開発
  • ゲーム動画の分散エンコーディングにさらなる力を mmfft9

MultiMachines Utilsに新たなツール、mmfft9が追加された。 mmfft9は”MultiMachines FFmpeg Tcp vp9”の略である。

本ツールは従来のmmffrと同じ目的で利用するが、用途を狭めてさらに強力にしたものである。

mmffrについての記事はこちら。

ここからは使っている人(おそらくいない)には無用だが、改めてこのツールの意義と目的を説明しよう。

ゲームプレイを(XBox Game Bar, Radeon ReLive, Shadow Playなどで)常時録画する、あるいは頻繁に録画するプレイヤーにとってはあまりにも大量にたまってしまう動画は悩みの種だ。 なんといってもこれら録画ファイルは30Mbpsを超えるような動画ファイルであるため、すぐディスクを圧迫してしまう。 そのため、動画を圧縮してアーカイブする必要があるのだが、libx265による変換でも4kだと実プレイ時間よりも短く変換するのは困難で、そもそも変換が間に合わない状況が発生する。

この問題は容易には解決しないため、プレイ時間が長い(プレイ頻度が高い)ゲーマーの場合はゲーミングPCとは別に動画変換に力を発揮するパワフルなPCが必要であるという前提があるのだが、それでもなかなか動画変換を「消化する」ことは難しい。 現状最高峰のPC(私だとRyzen9 5950X)を使ってもそれが現実なので、場合によってはそのようなPCを複数台用意して並列変換する必要がある。

mmffrはそのような目的を達成するために作られたキューシステムであり、マルチプロセスでffmpegを用いた動画変換を行う。 それは異なるホスト間でも利用できる。

複数ホストによるクラスタ処理が想定されているものの、それは前提ではない。多くの場合は単一ホストでも十分な威力を発揮する。 単一ホストで使うにはmmffrは過剰なソフトウェアであることは事実だが、だからといって使いづらいわけではない。基本的に変換コマンドを実行するだけであり、それをどのホストで実行するかの違いにすぎないからだ。 マルチスレッドへの最適化の甘いlibvpx-vp9においては、マルチプロセスでの並列処理はトータルの処理時間を短縮する。

mmffrが力を発揮する場面は、いくら変換しても次の変換ファイルが存在する、という状況だが、これが時に不便でもある。 mmffrを長く使ってきた中で私はより実用的なアプローチを考えるようになった。もちろん、mmffrは十分に機能し、目的を達成できるが、電気代的な意味でも、コンピュータの負荷という意味でも、またコンピュータがビジーな時間の長さという意味でも、よりストラテジー面で効率的に処理できるようにすることで改善の余地があると考えたのだ。

そのような改善を実現したのが今回のmmfft9である。

mmffrの問題点

mmffrを長く使っていて、いくつかの問題を感じている。

割と大きいのはmmffrの場合、タイトル単位でのワーカーとなるため、タイトル数が多い場合はそのタイトルの動画が処理しきれるタイミングを気にする必要がある、ということだ。 例えば原神で40本、BallisticNGで40本の動画があったとして、40並列にはできないので、原神で14ワーカーで変換を始めるわけだが、やがて処理が終わってアクティブなワーカー数は減っていく。 それに合わせてBallisticNGのワーカー数を増やしていかないといけないのだが、ここが手動になってしまう。

また、非対称な(処理能力に差のある)ホストによる並列処理をすると、長い動画を貧弱なホストが拾ってしまい、とても時間がかかるというケースがある。

無限に変換が終わらないという前提であればmmffrは非常に良いのだが、実際は「できれば早いところシャットダウンしたい」という気持ちがあるため、いまいち使い勝手がよくない。 原神の動画が100本、BallisticNGの動画が100本というような状況で、原神の動画の変換から始めたとして、どうしても「夜中寝ている間に処理が終わってしまう」というような状況は発生する。 原神を8ワーカー、BallisticNGを4ワーカーとかすれば完全に止まってしまうことは防げるが、ワーカーが減って非効率な状態が発生するのは避けられない。

シャットダウンできないという意味では、最後に長い動画を拾うだけで最悪だ。 長い動画だと4日くらい変換にかかったりするので(5950Xで4kのプレイ動画の処理速度は1FPS程度。2時間の動画で120時間=5日かかる)、最後だけ長い動画だったとかになると1プロセスだけいつまで経っても終わらず、シャットダウンできない日が何日も続くということがある。

このほか、mmffrはタイトルごとに独立したタプルスペースを立てるため、TCPのポート番号がどんどん埋まっていくという問題もあった。

解決への着想

mmffrの場合、Rindaを使って非常に高速に次の処理データをサーブする。 しかし、ffmpegによるエンコーディングは非常に時間がかかる(数日かかるようなケースも珍しくない)ため、サーブの高速さというのはほとんど影響がなく、ここに性能を割く意味は乏しい。

そこで単純にひとつのシングル化されたキューがあればタイトルごとに詰まってしまう問題は解決できる。 mmffrは処理プロセスの「カレントディレクトリ」に大きく影響されるが、例えば入力パス・出力パスともに絶対パスにしてしまえば無理な話ではないのだ。

なので、やりとりする「処理ファイル」データに基本的にはあらゆる処理情報(入出力パスやエンコーディングオプションなど)を持たせるようにすればこの問題は割と簡単に解決できる。 分散処理を可能にするためには考えるべきこともあるが、分散処理するケースは現状そこまで多くない(主にホストごとの処理能力と電力的な理由で)ため、手元の利便性としてはタイトルごとに詰まることをなくすのが大きい。

また、処理時間に関しては、libvpx-vp9で処理するということが決め打ちであればソースのファイルサイズにほとんどの場合比例する。 実際にはビデオサイズが違うと違ってくるのだが、雑に言えば比例すると考えて間違いではない。 なので、大きいファイルからサーブするようにすれば最後には小さいファイルが残る形になり、「1ファイルだけ処理が終わらずシャットダウンできない」といった時間を減らすことができる。

これはどういうことか。まず、ソースファイルのサイズは基本的に動画の内容によらずだいたい一緒である。 理由は、少なくともRadeon ReLiveは30Mbpsをターゲットビットレートとしたエンコーディングで保存しているためだ。 固定品質でエンコードした場合は動画のビットレートは大きく異なるが、ReLiveを見る限りではソース動画のファイルサイズはdurationであると考えて良い。 つまり、大きいファイルは長い動画だ。

画面サイズによってはこの限りではないのだが、実際のところサイズは処理速度ほどには影響しない。 4kとFHDだと、処理速度はだいたい2〜3倍程度違うが、ソース動画のビットレートは半分になんてならない。というより、FHDくらいのサイズがあれば30Mbpsになってしまう。 画面サイズが違うケースは多くはないが、このようなケースでは補正値が必要になる。

変換時は(私の考えでは)固定品質でエンコードするため、ビットレートは内容によって大きく異なる。 一方、処理速度は動画の内容による変化は小さい。BallisticNGは非常に動きが激しいゲームであり、処理速度も遅いが、動きがほとんどないアドベンチャーゲームと比べても処理速度の差は2倍程度の範囲に収まる。 処理速度に大きな差が出るのは画面サイズによるもので、あとは画面サイズ変換・フレームレート変換があると大きく変わってくる。

だが、これを分散まで広げると処理時間はファイルサイズに比例しない。非力なホストが中くらいのファイルを拾っていつまで経っても終わらない、ということもありえる。

これらを踏まえると達成したい解決方法は優先度が高い順に次のようになる:

  1. サーブするのをサイズの大きい順にする
  2. 複数タイトルの動画をひとつのキューに入れられるようにする
  3. 遅いホストはあまり大きいファイルをとらないようにする (かなり難しい)

なお、サーブする順序がソートされるようになると、途中でコケた場合に「処理されていないファイルはなにか」がわからなくなってしまうため、キューの保存/レジューム機能も必要になる。

基本のキュー機能

Rindaを使うのをやめて単純なTCPサーバーにした。

シングルプロセス・シングルスレッドで動作するため、競合などを考える必要がない。 候補リストを追加されたら、単純にそのプロセスが配列をサイズでソートし、候補を要求されたら一番大きいサイズの候補を返せば良い。

mmfft9は頻繁に再計算、ソート、ファイル書き込みなどを行うため、mmffrと比べると圧倒的に遅いが、ffmepgの処理時間と比べれば一瞬なので、そこはあまり気にならない。

また、コーデックが異なるとサイズに対する処理時間の関係が複雑になることと、より多くのスレッドで動作するlibx265が走っているとプロセス数を削らないといけないといった問題も発生することから、libvpx-vp9によってのみ処理するものとした。

サーバーはコマンドを受け取り、それに応じた処理をして必要ならば応答を返す。 このような処理はwebサーバーにすると楽だが、今回の場合プロセスが持っている変数で管理したいので単純なTCPサーバーにした。 一問一答であればリクエストを書いたらclose_writeし、受け取った側はEOFまで読んで、応答を書いたらcloseすれば良いので単純だ。

サーバーはほとんど追加リクエストでキューにconcatし、候補リクエストでshiftして返すものだが、追加リクエスト時はキューをサイズで降順ソートする。 これだけで全体の形は見えてくる。

def add list
  @queue.concat list
  @queue = @queue.sort_by {|i| [(i[] || 0), i[]] }.uniq.reverse
  pp @queue
end

パワー

ホストごとにどれくらいの処理能力があるか、は知りたいところである。

これを知るのは、「どのホストが高速か」という判定に使えるのと、後で利用予定があるホストを「1時間以内に終わる処理だけほしい」といった形で短時間参加させることができるというメリットがある。

前述のとおり、ソースサイズと処理時間はおよそ比例するため、ソースサイズ / 経過秒数で1秒あたり何バイト処理できるかが出る。 何らの理由で極端に速く処理できてしまうソースや、極端に時間がかかるソースもなくはないため、すべての処理についてこれを記録し、上下10%はカットすることにした。

パワーの記録はファイル処理完了時に行い、パワーのロードはmmfft9-run起動時に行う。

mmffr9-run--limitオプションを指定した場合、power * 60 * limitサイズを下回るもののみを候補としてリクエストする。 (つまり、limitは分単位である。)

このためにはpowerの計算ができている状態である必要がある。

パワーレート

前述のように、ソースサイズと処理時間はおよそ比例するが、画面サイズが異なったり、画面サイズ変換・フレームレート変換があるとソースサイズに基づく処理時間予測から大きく外れてしまう可能性がある。

mmfft9では動画ファイルのサイズを基準に動作するため、サイズと処理時間のバランスが崩れる場合、サイズに補正値をかけるのが手っ取り早い。これがpower_rateである。

パワーレートは設定ファイルに書くものなので、勘で書いてもいいが、実時間からどの程度ずれているかを出すこともできる。これがmmfft9-power.rbだ。

mmfft9-power.rbは、通常使うpowerファイルではなく、title_powerというDBMファイルを使う。 これはタイトル単位で計算されたパワーが記録されている。こうしたバランスはタイトルごとにおよそ一定だろうと推測されるため、タイトル単位となっている。

mmfft9-power.rbを実行すると、各タイトルの平均パワーが、全体の平均パワーに対してどれくらいの値であるかを示してくれる。 また、タイトルを引数として指定すると、全体の平均パワーとの比ではなく、引数で指定されたタイトルの平均パワーとの比になる。

ここで示された値を、ほぼそのままpower_rateに指定すればだいたい正確になる、というわけだ。 もちろん、それぞれのタイトルがある程度の数処理されている必要があるが。

また、極端に短い動画だったなどの理由で、大きく平均から外れる動画は除外されるようになっている。 これは、タイトルの全体平均を出したあと、そのレート以上平均から離れているデータを除外したリストを作り、残ったリストから平均を算出する。 デフォルトは0.4だが、-rで指定できる。

power_list = Marshal.load(v).sort
next if power_list.empty?
all_avg = power_list.sum / power_list.length
power_list = power_list.drop_while {|i| i < (all_avg - all_avg * @drop_rate) }
power_list = power_list.reverse.drop_while {|i| i > (all_avg + all_avg * @drop_rate) }
valid_avg = power_list.empty? ? 0 : power_list.sum / power_list.length
@avg[k] = valid_avg

title_powerも各ホストごとに計算されるし、もしかしたらホストにより(特にCPUタイプが違うと)差が出るかもしれないが、power_rateはホストごとに設定できるものではないので、中核となるホストで計算するのが良いだろう。 なお、powerpower_rateを踏まえた指定サイズをもとにするのに対し、title_powerは元のファイルサイズをもとに計算されるため、現在のpower_rateを気にする必要はない。

パワー再調整

基本的にパワーレートはtitle_powerには作用しないため、問題はpowerである。

パワーレートの調整を入れたといった理由で、パワーの計算を初期化したい場合、単純にpowerファイルを削除すれば良い。 ふたたび有効なpowerが計算されるまである程度の動画を処理する必要があるが、大きな支障はないだろう。

一方、ソース動画の画素数が変化したといった、パワーレートに関係なくサイズに対してかかる時間が大きく変動する要素があった場合、完全リセットするならtitle_powerを消せば良いのだが、こちらは蓄積にpowerより時間がかかる。 特定タイトルのみへの影響であれば、mmfft9-power.rb -D titleとすることで、そのタイトルだけリセットすることができる。

PCの構成に変更があったなど、計算能力が変化した場合もリセットが必要だ。

(mmfft9-power.rbを使って)各タイトルにパワーレートを設定する前提であれば、パワー値はただの基準値でしかない。 そのため、処理したタイトルによって変動の大きい全体平均を使うよりも、特定タイトルの平均値を基準として使いたくなる。 このような場合はホストの設定ファイルにpresumptive_powerを設定することで、パワーを明示的に固定することができる。 ここに基準タイトルの平均パワーを入力すれば、より精密に管理することができるだろう。

ホールド

分散時の問題は、例えば100ファイルあったとして、Ryzen5 5950XとXeon Sliver 4114では処理速度が2〜4倍くらい違うので、5950Xが走らせているスレッド数と同数、例えば14スレッド動作させている状態であれば、残りのファイル数が14になったらXeon Silverは着手しないようにしてもらったほうが良い。

手動でやるとしたらこのような調整であり、これでよければそこまで難しくない。 どのみち厳密な予測は難しいので、この理屈を採用した。

各ホストの設定としてholdsという値を持つ。

例えば5950Xのholds12を設定し、Xeon Sliverのholds16を設定したとする。 mmfft9-runによるリクエストにはpowerholdsが記載されており、サーバーはホストをpowerによって降順ソートする。 サーバーは候補リクエストに対して候補リストをshiftするが、この候補リストはリクエストしたホストよりも高速なホストがホールドしている分をオフセットしたスライスに対して行われる。

キューは破壊的に変更したいが、スライスからshiftするとそうならないので、ちょっとここは複雑な処理である。

つまり上の例では、5950Xが参加している状態でXeon Silverがリクエストすると通常、13番目の候補が応答される。 残りの候補が13個を下回る場合、byeが応答される。 もしもっと非力なホストが参加した場合は29番目の候補が応答される。

def take req
  leading = 0
  @holds.each do |x|
    leading += x[] if x[] != req[]
  end

  list = @queue[leading..]

  if list.empty?
    return {true}
  end

  entity = if req[]
    @queue.pop
  elsif req[]
    index = list.index {|i| i[] < req[]}
    if index
      @queue.delete_at(leading + index)
    else
      {true}
    end
  else
    @queue.shift
  end

  entity
end

また、非力なホストは小さいほうから取ったほうが良いため、小さいほうを要求するオプションも用意した。 これはコマンドラインオプションなので、参加するホストの中で最も非力なホストでの実行時に指定すれば良い。

処理中ホストのカウント

ホールドの処理のため、サーバーは参加しているホストを知る必要がある。

mmfft9-runは1回目のリクエストにはfirstフラグを立て、これを受けてサーバーはそのホストのワーカー数をインクリメントする。 これによってワーカー数が0から1になるのであれば、holdsに追加する。

逆にbyeを受け取ったとき、あるいは(返すべき候補がなく)byeを返したときはワーカー数をデクリメントする。 これによって≦0となった場合はholdsからも削除する。

こういう処理なので、mmft9-runが異常終了してしまうとカウントはおかしくなってしまう。 どうしてもそれが困るならサーバーを再起動すれば良いため、そこはあまり手当てしていない。

分散とファイルパス

mmfft9による動画処理の分散は(mmffrも同様に)明確な問題があり、それはソースファイルにどのホストからもアクセスできなくてはならないということだ。

ソースファイルは非常に大きいため、処理前にソースファイルを転送するようにすると処理時間はかなり伸びてしまう。 VP9への変換の速度はかなり遅いため、通常はSSHFSで共有する程度で問題を生じないが、極端に多くなると問題が生じる。

また、SSHFSで共有されている前提で考えると、絶対パスで指定されるとホストによって異なってしまう可能性がある。

こうした問題を解決するのが、~/.config/reasonset/mmfft9.yamlである。

このファイルはホスト固有であるため、$sourcedir(=$source_base_dir)の値はホスト固有だ。 ソースファイルのパスは$source_base_dir/$source_prefix/$filepathなので、$source_base_dirがSSHFSのマウントポイントになっていれば整合性が取れる。

離脱

現在の動画が終わったら、次の候補を取得せずに終了したい場合、~/.local/state/reasonset/mmfft9/exitファイルを作成すると、mmfft9-run.rbは次のファイルを取らずに終了する。

これにより、サーバー以外についてはある程度任意のシャットダウンすることができる。利用時を避けてffmpegを回したい場合などに役に立つ。

if File.exist? "#{@state_dir}/exit"
  Marsha.dump({
    ,
    @config["hostname"]
  })
  break

次に開始する前にこのファイルを消すことを忘れないこと。

永続

mmfft9-server.rbは自身が終了するとき、キューに残りがあるときは~/.local/state/reasonset/mmfft9/queue.rbmファイルにキューを保存し、次に再開するときに復元する。

キューを初期化したい場合はこのファイルを消せば良い。

しかし、このXDGステートディレクトリ、Manjaroだと標準で作られてないんだよね。

エラー

処理でエラーが発生すると、~/.local/state/reasonset/mmfft9/errors.yamlにエラーの詳細が保存される。

これはキューアイテムをそのまま保存したものなので、mmfft9-q.rb -r ~/.local/state/reasonset/mmfft9/errors.yamlとすることで復帰させることができる。 ただし、エラーになったアイテムなので、そのまま戻してもエラーになると思われるので要注意。 また、再度エラーになるとそのerrors.yamlにアイテムが追加されてしまうから、事前に移動しておいたほうが良い。

clip

styleclipのものはmmfft9-q.rbに渡されるファイルのフォーマットが異なったものになる。これはタブ文字区切りで

  1. ソースファイルパス
  2. -ss
  3. -to
  4. 出力ファイル名

となる。

プレイ動画を全部アーカイブするのではなく、特定のハイライトだけを切り抜いて保存したい場合に有用。私は主に音ゲーのプレイ動画に使っている。

-ssは開始時間、-toは終了時間。-tと違って長さではない。

利用例

サーバーホストの設定

mmfft9-server.rbを実行するホストはひとつだけなので、まずはそのホストから。 ~/.config/reasonset/mmfft9.yamlをsampleからコピーして書く。

outdir

出力先のベースディレクトリ。$titleサブディレクトリ以下に変換されたファイルが出力される。ホスト固有。

sourcedir

ソースファイルがまとまっているベースディレクトリ。prefixが別途存在するため、通常は$sourcedir/$titleになるようにするが、そうでなくても良い。

host

サーバーのアドレス。ホスト名でも良いが、IPv6でうまくいかないとかあるかも。

hostname

そのホストの名前。普通にhostnameを入れるのが無難だけれど、IDなのでかぶらなければなんでも良い。

holds

そのホストが最低限走らせるであろう処理スレッド数。私の場合、5950Xは14スレッドで回すことが多く、回しながら使いたい場合は8スレッド程度に抑える。なので最低だと8だが、12スレッド分確保することにした。

ソースの設定

次にソースディレクトリ以下にタイトルごとのディレクトリを作る。

mkdir Genshin

そして、そのディレクトリにdot.mmfft9.yaml.mmfft9.yamlとしてコピーし、編集。

prefix

ソースのベースディレクトリからこのタイトルディレクトリまでのパス。

title

出力のベースディレクトリからファイルを出力するディレクトリまでのパス。通常は/を含まないようにする。(/を含むと事前に作っておく必要がある。)

prefixと1:1である必要はないため、styleの異なるものについて、ソースディレクトリは分けつつ、出力先は共有することもできる。

style

ReLiveで保存している場合はReLiveが良い。ここはちょっとややこしいが、わからなければ指定しないでOK。

power_rate

ソースは60FPSだけど、動画は30FPSを指定しているとか、scale指定があるとか、極端に処理が重い/軽い動画だとかいった事情に基づくサイズの変換用。

ff_options

ffmpegのオプション。mmffrと違い、指定できるものは限定的。rは指定しておくほうが無難だと思う。

意図によって自ず決まってくるので、悩むところは少ないと思う。 CRFのデフォルト値は44。将来的に保証はしてないので、意図した値があるなら明示したほうが良い。 「44は高い」と思うかもしれないが、H.264とはそもそも値が違う(し、値のレンジも違う)のでそちらに引きずられるべきではない。 ゲーム動画の場合、高画質を狙うなら36から38あたりだが、圧縮率はだいぶ下がる。30とかを指定すると、圧縮する意味がほとんどなくなる。 1080Pの推奨値は31だが、かなり画質に寄った設定だと思う。アーカイブ目的には厳しい。

ff_optionsで指定できる値は以下:

key as option default
r -r
crf -crf 44
min -minrate
max -maxrate
tile -tile-columns
threads -threads
speed -speed
cpu -cpu-used
quality -quality
vf -vf
ba -b:a 128k

libvpx-vp9/libopusに固定されているため、指定できるものはピンポイント。

ソースの配置とリスト

ソースタイトルディレクトリ以下にソースファイルを配置する。 私はvというディレクトリを作ってその中に入れている。

そして、リストを作成する。clipなどのスタイルを除けば

print -l v/*.mp4 >| list

というのが手っ取り早い。

処理の開始

mmfft9-server.rbを開始し、mmfft9-q.rb listのようにしてキューに追加する。

mmfft9-q.rbに関しては実行は当該.mmfft9.yamlのあるソースタイトルディレクトリである必要がある。

あとはmmfft9-run.rbを実行するとワーカーが生成される。並列処理したい数だけワーカーを起動すれば良い。

ホストの追加

参加したいホストにもそのホスト固有の~/.config/reasonset/mmfft9.yamlを書き、出力ディレクトリを用意し、ソースディレクトリをSSHFSでマウントする。

あとはmmfft9-run.rbを実行すれば参加することができる。

注意書き

mmfft9は、まだ私が使い始めたばかりのツールであり、またテストには非常に時間がかかるものであるため、潜在的にバギーである。

というか、絶対まだまだバグはある。 タイミングによるが、そもそも動作しないかもしれない(ちょいちょいバグをみつけてはpushしてる)。

mmfft9の有用性

私にとっては、(mmffrを含めて)日常的に重宝している、2番目に使っている自作ソフトウェアではある。 ただ、前提が少なくとも

  • 日常的にゲームを録画し
  • それらをアーカイブして残し
  • (おそらくゲームするのとは別に) 動画変換に回せる高性能PCがある
  • 年間数TBの増加に耐えるストレージがある

を満たす必要があるため、かなりハードルが高く、求めている人は少ないものだろう。 まぁ、誰も使わなかったとしても私には必要なものなのであまり問題はない。